diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..482e201 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.DS_Store +.env +.env.* +reference-server/.env +reference-server/.env.* +reference-server/node_modules/ +reference-server/data/*.sqlite +reference-server/data/*.sqlite-* +reference-server/data/demo-runs/ diff --git a/README.md b/README.md index f9e365e..31d033d 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,113 @@ The project is offered as a **community proposal**, not a finished standard. The --- +## Current Direction + +The project is now being taken in a more execution-oriented direction: not just a family of proposal specs, but an **executable reference stack** for a bank-to-VASP blockchain payment flow. The current implementation wedge is intentionally narrow: + +- one asset: USDC +- one chain family: EVM +- one corridor: bank → sending VASP → on-chain transfer → receiving VASP +- linked Travel Rule record + remittance information + instruction lifecycle + +This repo therefore has two parallel layers: + +- **specification layer** — the YAML specs and standalone simulators at the repo root +- **reference implementation layer** — the live server under `reference-server/` + +The near-term goal is to prove that the existing specs survive real request validation, persistence, state transitions, and on-chain lifecycle handling before the family expands further. + +### Reference Stack Status + +The first executable slice lives in `reference-server/` and currently supports: + +- Travel Rule submit, update, callback, retrieval, search, and stats +- instruction quote, submission, retrieval, cancellation, and search +- pacs.002-like execution status read endpoints by `instruction_id` and `uetr` +- camt.025-like finality receipt read endpoints by `instruction_id` and `uetr` +- event outbox endpoints that mirror execution-status and finality-receipt payloads for webhook-style delivery +- webhook endpoint registration, signed delivery attempts, and retry logs on top of the outbox +- `camt.054`-like reporting notifications for booked debit and credit entries +- `camt.052`-like intraday movement view built from the reporting notification feed +- `camt.053`-like statement view derived from reporting notifications and instruction context +- first-slice exception-family endpoints for `investigation_case` and `return_case` +- live statement and reporting traceability views in the instruction simulator +- adapter-backed mocked EVM lifecycle progression with amount-aware fee, slippage, and finality modeling: + `PENDING → BROADCAST → CONFIRMING → FINAL` +- adapter metadata surfaced on quote, instruction, execution-status, and finality reads +- webhook delivery stats and dead-letter reads for exhausted subscription attempts + +The two HTML simulators can still run standalone in **Demo** mode, but now also support **Live API** mode against the local reference server. + +### Quick Start + +Run the reference server: + +```bash +cd reference-server +npm install +npm start +``` + +Then open either simulator locally: + +- `travel-rule-simulator-v3.html` +- `instruction-simulator-v1.html` + +Switch **Execution Mode** to `Live API` and keep the default API base URL `http://127.0.0.1:5050`. + +### Roadmap And Backlog + +The active forward plan is now documented in: + +- [`docs/roadmap.md`](docs/roadmap.md) — active post-wedge roadmap for real Sepolia execution, reviewer demo, deeper exceptions, and delegated signing +- [`docs/backlog.md`](docs/backlog.md) — prioritized post-wedge backlog with `P0/P1/P2/P3` sequencing +- [`docs/conformance.md`](docs/conformance.md) — current spec-to-server conformance matrix +- [`docs/spec-hardening.md`](docs/spec-hardening.md) — implementation decisions for lifecycle, failure, webhook, and reporting semantics +- [`docs/chain-adapter.md`](docs/chain-adapter.md) — current adapter contract and swap-in boundary for later testnet work +- [`docs/webhook-delivery.md`](docs/webhook-delivery.md) — delivery guarantees, retry model, and dead-letter handling +- [`docs/demo-bank-to-vasp.md`](docs/demo-bank-to-vasp.md) — reviewer walkthrough, sequence diagram, and live demo script +- [`docs/visual-showcase.html`](docs/visual-showcase.html) — reviewer-facing visual artifact page that keeps the draft PR non-normative +- [`docs/visual-flow-map.md`](docs/visual-flow-map.md) — visual lifecycle map for Travel Rule, instruction, status, returns/reversals, and draft investigation flows +- [`docs/architecture-note.md`](docs/architecture-note.md) — what changed architecturally once the proposal became executable +- [`docs/demo-samples/happy-path/`](docs/demo-samples/happy-path/) — canonical happy-path payload pack +- [`docs/exception-family.md`](docs/exception-family.md) — implemented first-slice exception-family design and runtime boundaries +- [`docs/reference-stack-plan.md`](docs/reference-stack-plan.md) — original pivot plan that led to the current implementation + +### Current Baseline + +Implemented now: + +- executable reference server with persistence, state transitions, and tests +- live simulator support for the Travel Rule and instruction flows +- status, finality, webhook, reporting, and first-slice exception read/write surfaces + +Still mocked or partial: + +- chain lifecycle remains mocked, but now runs through an adapter-backed fee/finality policy with surfaced adapter metadata +- webhook delivery is background-driven with retries, dead-letter handling, and operator stats, but still demo-grade rather than production-hardened +- spec-covered conformance is explicit and tested, but still hand-authored rather than YAML-generated +- no delegated signing implementation +- Sepolia adapter path exists, but a funded-wallet live transaction still needs to be run and captured +- reviewer/demo package is now present, but still built around the current mock EVM wedge + +Current next defaults: + +- first real execution target: `Ethereum Sepolia` +- first real asset: `USDC on Sepolia` +- first real execution mode: `FULL_CUSTODY` +- first real-chain demo audience: Tom-facing reviewer walkthrough + +Explicitly deferred: + +- non-EVM chains +- tokenized assets +- CBDC +- regulated DeFi +- agent-driven submission + +--- + ## Released specifications ### Spec 1 — Travel Rule & Remittance Information API @@ -182,20 +289,34 @@ Banks connecting to EBSI — or any EVM-compatible chain — via a VASP or gatew ## Roadmap — where the family goes next -These are areas under active exploration. Contributions, challenges, and alternative proposals are all welcome. +The current implementation roadmap is execution-first and narrow by design. Broader family expansion remains valuable, but the immediate credibility target is to prove one real bank-to-VASP-to-chain corridor end to end. -### 1. Agent-driven submission — OpenClaw integration +Current priority order: + +1. implement real `Sepolia + USDC` execution behind the existing adapter seam +2. update the reviewer demo so one path includes real chain evidence +3. deepen exception handling on top of real-chain outcomes +4. add delegated signing on the same corridor +5. keep broader expansion deferred until the Sepolia-backed wedge is proven + +The detailed program of record lives in [`docs/roadmap.md`](docs/roadmap.md) and [`docs/backlog.md`](docs/backlog.md). + +### Agent-driven submission — OpenClaw integration Personal AI agent platforms such as [OpenClaw](https://openclaw.ai) are emerging as a new kind of user interface to structured APIs — capable of assembling, validating, and dispatching API calls on behalf of a user, from natural language instructions, via any chat application. All three pacs.crypto APIs are well suited to agent-driven submission. A returning VASP customer — whose identity has already been KYC-verified — could instruct their OpenClaw agent via WhatsApp or Telegram: *"send 0.5 ETH to this address for invoice INV-042"*. The agent, holding the user's verified identity fields in its persistent memory, constructs the full pacs.crypto submission, attaches the structured remittance information, and calls the VASP's API endpoint automatically. The VASP's KYC obligation is unchanged; what changes is the quality and consistency of the data arriving at the API — pre-formatted, correctly structured, with remittance detail already attached. -A reference OpenClaw skill for pacs.crypto submission is planned as a concrete deliverable. +A reference OpenClaw skill for pacs.crypto submission is a later deliverable, after the Sepolia-backed reference flow is proven. + +### Deferred expansion candidates -### 2. Further family members under consideration +These remain intentionally out of the current 12-month wedge: +- **Non-EVM chain profiles** — Bitcoin, Solana, XRPL, and other settlement layers once the EVM corridor is operationally proven - **Tokenised asset transfers** — extensions for regulated tokenised securities, CBDCs, and stablecoin issuers where additional asset-specific fields and regulatory reporting requirements apply; the `credential_attestation` field introduced in the current specs is the foundation for this work - **Regulated DeFi** — how the pacs.crypto data model applies when one or both counterparties interact via smart contract rather than a custodial VASP +- **Agent-driven submission** — practical OpenClaw or equivalent submission tooling once the core API and reference execution path are stable ### 3. Pre-execution intelligence API — later phase diff --git a/docs/architecture-note.md b/docs/architecture-note.md new file mode 100644 index 0000000..0b03336 --- /dev/null +++ b/docs/architecture-note.md @@ -0,0 +1,116 @@ +# Architecture Note + +This note explains what changed once `pacs.crypto` moved from proposal specs to +an executable reference stack. + +## Original Center Of Gravity + +The original repo strength was standards framing: + +- `pacs.008` alignment +- blockchain-specific field extensions +- Travel Rule and instruction proposals +- simulator-driven readability + +That was useful, but still left an open question: + +- would the model stay coherent once it had to persist state, expose lifecycles, + and produce operational outputs? + +## What Changed In Implementation + +### 1. The repo narrowed to one wedge + +Instead of expanding breadth, the implementation fixed scope to: + +- `USDC` +- one `EVM` chain family +- one `FULL_CUSTODY` model +- one bank-to-VASP payment corridor + +This was the right tradeoff because credibility comes from one narrow flow that +works, not from a broader but shallower standards family. + +### 2. The instruction API stopped carrying everything + +A major implementation lesson was that blockchain payment execution should not +collapse into one giant instruction response. + +The current split is: + +- `instruction` as the command/orchestration surface +- `execution-status` as the lifecycle read surface +- `finality-receipt` as settlement proof +- reporting surfaces for booked-entry outputs + +That is the closest analogue to how real payment ecosystems separate command, +status, settlement, and reporting concerns. + +### 3. Lifecycle realism moved behind an adapter + +Another key change was architectural rather than presentational: + +- quote realism +- fee modeling +- broadcast/inclusion/finality timing +- settlement defaults + +now live behind a chain adapter boundary. + +That matters because later testnet work can replace adapter internals without +rewriting the route layer. + +### 4. Push delivery became an outbox problem + +The implementation made webhook behavior explicit: + +- outbox event persistence +- canonical payload reuse +- signed delivery attempts +- retries +- dead-letter handling and operator reads + +This is more credible than an undocumented “status callback” story because the +repo now shows how push and poll relate operationally. + +### 5. Reporting became a first-class family + +A useful implementation outcome was that reporting could not remain an afterthought. + +The repo now includes: + +- `camt.054`-like booked notifications +- `camt.052`-like intraday view +- `camt.053`-like statement view + +Those surfaces reuse the same identifiers as instruction and status reads, which +is what makes the stack feel operationally coherent. + +## What Stayed Intentionally Narrow + +The implementation did not try to win by multiplying surface area. + +Still deferred: + +- delegated signing +- non-EVM chains +- testnet execution +- exception-family APIs +- broader tokenized-asset/CBDC/DeFi expansion + +That restraint is part of the design quality, not a missing ambition signal. + +## Why This Matters + +The project now demonstrates something materially stronger than “an ISO 20022 +proposal for crypto payments.” + +It demonstrates: + +- a message-family disciplined reference architecture +- executable request/response behavior +- traceable lifecycle state +- believable settlement semantics for the current wedge +- booked reporting derived from the same payment objects + +That is the architectural shift that makes the repo credible to a standards-savvy reviewer. diff --git a/docs/backlog.md b/docs/backlog.md new file mode 100644 index 0000000..8a8d8f9 --- /dev/null +++ b/docs/backlog.md @@ -0,0 +1,215 @@ +# pacs.crypto Backlog + +## How To Read This Backlog + +- `P0` means current top priority. +- `P1` means next after `P0`. +- `P2` means important but sequenced after the current real-chain wedge is proven. +- `P3` means deferred expansion only. + +Status values: + +- `Done` +- `In progress` +- `Planned` +- `Deferred` + +## Baseline Already Landed + +These are the completed foundations for the next phase. + +### Foundation stack +Priority: complete +Status: `Done` + +- reference server with persisted Travel Rule, quote, instruction, status, finality, webhook, reporting, and first-slice exception state +- live simulator support for Travel Rule and instruction flows +- pacs.002-like status reads and camt.025-like finality reads +- outbox-backed webhook subscriptions, retries, dead-letter handling, and signed delivery attempts +- reporting notifications, intraday views, and statements +- root `/report/*` pull routes now emit camt-style wrappers on top of the internal reporting records +- first-slice `investigation_case` and `return_case` runtime surfaces +- reviewer demo package, architecture note, and sample payload pack + +## Active Defaults + +These defaults are now part of the backlog, not open questions: + +- first real execution target: `Ethereum Sepolia` +- first real asset: `USDC on Sepolia` +- first real execution mode: `FULL_CUSTODY` +- primary audience: `Tom-facing reviewer demo` +- implementation seam: replace adapter internals before changing public routes +- current public status/finality/reporting/exception surfaces remain canonical + +## Now + +### Epic 10A - Execution safety and evidence hardening +Priority: `P0` +Status: `Implemented - pending funded validation` +Depends on: completed chain-adapter boundary + +Work items: + +- make real broadcast an explicit single-writer operation rather than a side + effect of read/search/reporting paths +- prevent duplicate Sepolia transfers under concurrent polling or duplicate + lifecycle advancement +- enforce the narrow live corridor before transfer: + - debtor wallet must match the configured signing/source wallet + - chain must be Sepolia DLI + - token must be USDC DTI/symbol + - settlement currency must be USD +- verify ERC-20 `Transfer` logs before claiming correct final settlement +- fix debtor-side reporting so a debit observed at `BROADCAST` cannot stay + permanently pending after the instruction reaches `FINAL` +- emit reporting balances with unsigned amounts plus credit/debit indicators +- harden preflight and demo evidence generation so failed or non-final runs + cannot be packaged as reviewer proof +- correct USDC token identifiers in demo tooling and sample evidence + +Current status: + +- implemented: submit-only live broadcast boundary, concurrency/idempotency guard, + Sepolia corridor checks, ERC-20 transfer-log verification, debtor reporting + upgrade, unsigned balance evidence, and demo/preflight failure gates +- automated Sepolia lifecycle coverage uses injected provider/signer stubs and + now checks transfer-log evidence, but still does not prove funded safety +- real funded-wallet broadcast is ready for controlled validation once + credentials, funds, and a recipient wallet are configured + +Acceptance criteria: + +- no `GET`, search, reporting, duplicate-check, or list path can submit a real + transaction +- concurrent polling cannot produce duplicate broadcasts for the same + instruction +- the adapter rejects source/debtor mismatch, wrong chain, wrong token, and + wrong settlement currency before transfer +- finality receipt verifies the ERC-20 transfer log for token contract, sender, + recipient, and amount +- debtor reporting upgrades from pending to booked after final settlement +- preflight fails when configured funds are insufficient for the demo amount +- demo runner exits non-zero unless execution status and finality are both + `FINAL` + +### Epic 10 - Testnet execution +Priority: `P1` +Status: `Ready for funded validation` +Depends on: Epic 10A + +Work items: + +- implement a real `Sepolia USDC` adapter behind the existing chain-adapter contract +- keep `POST /instruction`, `GET /execution-status`, and `GET /finality-receipt` route shapes unchanged +- replace mock broadcast/finality state with real transaction submission, tracking, and confirmation reads +- surface real execution context through existing `adapter_metadata` +- keep the first live path restricted to `FULL_CUSTODY` + +Current status: + +- `sepolia-usdc` adapter exists and is opt-in by environment +- mock adapter remains default +- read-only Sepolia mode is covered by tests +- happy-path Sepolia broadcast, confirmation, and reporting linkage are now covered in automated tests through injected provider/signer stubs +- incomplete broadcast configuration fails safely +- wrong-network RPC configuration now fails safely +- preflight and demo-run scripts now exist for the funded-wallet path +- live execution is unblocked from the Epic 10A safety review perspective +- real funded-wallet broadcast still needs to be run and captured + +Acceptance criteria: + +- one instruction can produce a real Sepolia transaction hash through the existing instruction flow +- `execution-status` and `finality-receipt` are populated from real chain state without route redesign +- the same identifiers still join instruction, webhook, reporting, and exception records +- the mock adapter remains available as a fallback/demo path + +## Next + +### Epic 11 - Demo with real chain evidence +Priority: `P1` +Status: `Planned` +Depends on: Epic 10A and Epic 10 + +Work items: + +- update the reviewer walkthrough so one canonical scenario runs against the Sepolia adapter +- publish one sample path with real tx hash, real confirmations, and real finality receipt output +- distinguish clearly between `mock demo` and `real-chain demo` in docs and simulator guidance +- keep the narrative optimized for Tom review rather than public platform packaging + +Current status: + +- real-chain demo runner and preflight scripts are in place for the funded-wallet path +- reviewer-summary generation is now scripted so a captured run can be turned into a Tom-facing markdown evidence pack immediately +- reviewer evidence generation must not be used for a funded run until Epic 10A is complete + +Acceptance criteria: + +- a reviewer can follow one bank-to-VASP happy path backed by a real Sepolia transaction +- the live demo materials show real chain evidence without changing the message-family story +- the repo no longer describes testnet execution as absent once Epic 10 is done + +### Epic 12 - Deepen exception handling +Priority: `P1` +Status: `In progress` +Depends on: Epic 10A and Epic 10 for real-chain evidence linkage + +Work items: + +- deepen `investigation_case` statuses, transitions, and operator workflow +- deepen `return_case` remediation semantics around real-chain versus off-chain outcomes +- tighten linkage from exception objects to real-chain evidence and reporting consequences +- keep bilateral cancellation deferred unless real operator flow proves it is necessary + +Current status: + +- investigation cases now enforce explicit lifecycle transitions and closure requirements +- return cases now enforce method-specific settlement evidence for on-chain versus off-chain remediation +- both exception families can now link directly to specific reporting notifications and statements for the same instruction + +Acceptance criteria: + +- investigation and return cases can model post-settlement follow-up against real-chain outcomes +- original instruction and finality records remain authoritative and are not overwritten +- exception workflow remains a separate family rather than leaking into execution-state surfaces + +## Later + +### Epic 13 - Delegated signing +Priority: `P2` +Status: `Planned` +Depends on: Epic 10 and Epic 11 + +Work items: + +- implement the currently stubbed delegated-signing path on the existing instruction family +- keep the first delegated flow EVM-only and aligned to the Sepolia wedge +- support unsigned transaction return plus signed transaction resubmission without inventing a parallel API family +- update conformance docs and demo materials once the flow is credible + +Acceptance criteria: + +- delegated signing works on the same instruction lifecycle and status/finality surfaces +- the bank/VASP split is credible for the existing corridor +- the flow remains narrower than a general multi-chain signing framework + +## Deferred + +### Epic 14 - Broader expansion +Priority: `P3` +Status: `Deferred` +Depends on: completion of Epics 10 through 13 + +Deferred items: + +- non-EVM chains +- tokenized assets +- CBDC +- regulated DeFi +- agent-driven flows + +Rule: + +- none of these start before real Sepolia execution, real-chain reviewer demo, deeper exception handling, and delegated signing are either implemented or explicitly superseded diff --git a/docs/chain-adapter.md b/docs/chain-adapter.md new file mode 100644 index 0000000..a7fd71b --- /dev/null +++ b/docs/chain-adapter.md @@ -0,0 +1,124 @@ +# pacs.crypto Chain Adapter Contract + +This document defines the current chain-adapter seam for the executable +reference stack. + +The goal is not to model every future chain integration today. The goal is to +keep the API layer stable while letting lifecycle realism move from: + +- simulated adapter +- to testnet adapter +- to later production-facing adapter work + +without redesigning the route layer. + +## Current Contract + +An adapter is identified by: + +- `id` +- `mode` +- `chain_family` + +The current normalized contract supports these methods. Methods may be +synchronous for the mock adapter or asynchronous for real-chain adapters: + +- `hasExpired(expiryDateTime)` +- `buildFeeEstimate(request)` +- `buildQuoteResponse(request)` +- `normalizeOnChainSettlement(onChainSettlement, amount, input)` +- `getLifecycleTimestamp(record, status)` +- `deriveLifecycleState(record)` +- `describeLifecycle(input)` + +`buildApp()` now normalizes the supplied adapter against the default mock EVM +adapter. That means later adapters can override only the methods they need +while the remaining behavior stays stable. + +## Current Ownership Boundary + +The adapter now owns: + +- quote realism +- fee estimate modeling +- settlement defaults +- lifecycle advancement +- lifecycle timestamps +- lifecycle metadata returned to clients + +The route and storage layers no longer own these concerns directly. + +## Metadata Surfaces + +Adapter-derived metadata is now surfaced through existing reads rather than new +endpoints. + +Current surfaces: + +- `POST /instruction/quote` +- `POST /instruction` +- `GET /instruction/{instructionId}` +- `GET /execution-status/{instructionId}` +- `GET /execution-status/uetr/{uetr}` +- `GET /finality-receipt/{instructionId}` +- `GET /finality-receipt/uetr/{uetr}` + +The metadata is exposed as `adapter_metadata`. + +The current mock EVM adapter publishes: + +- adapter identity and mode +- chain family and `chain_dli` +- congestion and lifecycle timing policy +- confirmation threshold policy +- fee-model assumptions + +This gives later testnet work a stable place to expose chain-specific execution +context without widening the API family. + +## Current Adapters + +The default adapter is `mock-evm`. + +It remains simulated, but it now models: + +- amount-aware fee estimates +- ramp-aware slippage checks +- confirmation thresholds +- probabilistic to final settlement progression +- deterministic transaction and block references + +The first testnet adapter is `sepolia-usdc`. + +It is opt-in through `REF_SERVER_CHAIN_ADAPTER=sepolia-usdc` and supports: + +- read-only mode without private key material +- USDC transfer broadcast when RPC URL, private key, source address, contract + address, and broadcast flag are configured +- transaction receipt polling through the same status/finality reads +- confirmation-threshold finality based on Sepolia block depth + +## Swap-In Rule For Testnet Work + +Future testnet work should respect these rules: + +- keep the existing route shapes +- preserve `execution-status` and `finality-receipt` as the canonical read models +- replace adapter internals before touching route contracts +- use `adapter_metadata` for execution context rather than inventing separate per-chain status objects + +That is the boundary that keeps the current wedge narrow and credible. + +## Current Target Status + +The active backlog now assumes: + +- first real chain target: `Ethereum Sepolia` +- first real asset: `USDC on Sepolia` +- first real execution mode: `FULL_CUSTODY` + +Those defaults should be treated as the first testnet adapter target unless the +program of record is intentionally changed. + +The first adapter implementation is now in place. The remaining work is to run +and capture a real Sepolia transaction for the reviewer demo package. diff --git a/docs/conformance.md b/docs/conformance.md new file mode 100644 index 0000000..6b2a35b --- /dev/null +++ b/docs/conformance.md @@ -0,0 +1,92 @@ +# pacs.crypto Conformance Matrix + +This matrix tracks how the current `reference-server/` aligns with the root OpenAPI specifications. + +Status meanings: + +- `Implemented` means the current server exposes the spec-covered route and the request/response shape is intentionally aligned for the current wedge. +- `Partial` means the route exists, but the behavior or wire shape still diverges from the spec in a documented way. +- `Out of scope` means the route exists in the spec but is intentionally not implemented in the current wedge. +- `Extension` means the route exists in the reference server but is not defined in the current root YAML specs. + +## Spec-covered surfaces + +| Endpoint | Spec | Request schema | Response schema | Status | Notes | +|---|---|---|---|---|---| +| `POST /travel-rule` | `travel-rule-api-v3.yaml` | `TravelRuleSubmission` | `TravelRuleRecord` | Implemented | Request validation now enforces the main required pacs.008-style objects used in the current wedge. | +| `GET /travel-rule/{recordId}` | `travel-rule-api-v3.yaml` | n/a | `TravelRuleRecord` | Implemented | Returns the persisted compliance record. | +| `PUT /travel-rule/{recordId}` | `travel-rule-api-v3.yaml` | `TravelRuleSubmission` | `TravelRuleRecord` | Implemented | Correction flow remains tied to the current record lifecycle model. | +| `POST /travel-rule/{recordId}/callback` | `travel-rule-api-v3.yaml` | `TravelRuleCallback` | `TravelRuleCallbackReceipt` | Implemented | Request validation follows the callback schema and the route now returns the receipt object defined in the spec. | +| `GET /travel-rule/search` | `travel-rule-api-v3.yaml` | query params | `TravelRuleSearchResponse` | Implemented | Query validation now covers the spec-defined filter set used in the current server, including direction, status, callback status, currency, wallets, pagination, and sort. | +| `GET /travel-rule/stats` | `travel-rule-api-v3.yaml` | query params | `TravelRuleStatsResponse` | Implemented | Stats envelope and aggregate totals are present for the current local dataset. | +| `POST /instruction/quote` | `instruction-api-v1.yaml` | `QuoteRequest` | `QuoteResponse` | Implemented | Request validation now enforces token, DLI, amount, currency, and custody model fields. | +| `POST /instruction` | `instruction-api-v1.yaml` | `PaymentInstruction` | `InstructionResponse` | Implemented | Current validation enforces the main mandatory pacs.008-derived parties, agents, amount, charge bearer, and blockchain instruction fields. | +| `GET /instruction/{instructionId}` | `instruction-api-v1.yaml` | n/a | `InstructionStatusResponse` | Implemented | The returned object includes the required status surface plus extra reference-server fields. | +| `DELETE /instruction/{instructionId}` | `instruction-api-v1.yaml` | n/a | `CancellationResponse` | Implemented | The route now returns the narrow cancellation receipt defined in the spec. | +| `POST /instruction/{instructionId}/signed-transaction` | `instruction-api-v1.yaml` | `SignedTransactionSubmission` | delegated-signing response | Out of scope | Delegated signing is intentionally not implemented in the current wedge. | +| `GET /instruction/search` | `instruction-api-v1.yaml` | query params | `InstructionSearchResponse` | Implemented | Search envelope, compact summaries, and query validation for status, DLI/DTI, pagination, and time range are present. | +| `POST /instruction/{instructionId}/return` | Tom upstream `instruction-api-v1.2.yaml` | `ReturnRequest` | `CompensatingInstructionResponse` | Implemented | Materializes a real compensating instruction and stores a Tom-origin `RETURN` exception-family case without rewriting the original final instruction. | +| `POST /instruction/{instructionId}/reverse` | Tom upstream `instruction-api-v1.2.yaml` | `ReversalRequest` | `ReversalRequestResponse` | Implemented | Records a Tom-origin `REVERSAL` exception-family request in `REQUESTED` state; no compensating instruction is created at request time. | +| `GET /instruction/{instructionId}/reversal-status` | Tom upstream `instruction-api-v1.2.yaml` | n/a | `ReversalRequestStatus` | Implemented | Returns the latest reversal request for the original instruction and only exposes compensating-instruction fields after a later accepted/completed outcome. | +| `POST /report/query` | `camt-crypto-reporting-v1.yaml` | `ReportQuery` | `QueryResponse` | Partial | Supports synchronous balance and intraday responses, synchronous or async statement delivery, and wallet-scoped notification subscribe/unsubscribe flows on top of the current webhook subsystem. Notification subscriptions now deliver raw camt.054-style bodies and async statements are queued through the retrying delivery engine. The route remains partial because the bank-side callback endpoint and full request idempotency window are still out of scope. | +| `GET /report/intraday` | `camt-crypto-reporting-v1.yaml` | query params | `IntradayReport` | Implemented | The route now returns a root-spec camt.052-style wrapper with `group_header`, `report`, paginated `entries`, and per-token balance lines built from the reference-server reporting records. | +| `GET /report/statement` | `camt-crypto-reporting-v1.yaml` | query params | `WalletStatement` | Implemented | The route now returns a root-spec camt.053-style wrapper with `group_header`, `statement`, paginated booked entries, and statement-period balance lines for the current wallet/date filters. | +| `GET /report/notification/{notificationId}` | `camt-crypto-reporting-v1.yaml` | n/a | `BlockchainNotification` | Implemented | The route now returns a root-spec camt.054-style wrapper with `group_header`, `account`, and `entry` derived from the underlying reporting notification and instruction context. | +| `POST /report/notification/callback` | `camt-crypto-reporting-v1.yaml` | `BlockchainNotification` | acknowledgement | Out of scope | This is explicitly a bank-side endpoint. The reference server is the VASP side and returns `501` to make that boundary explicit. | +| `GET /report/search` | `camt-crypto-reporting-v1.yaml` | query params | `EntrySearchResponse` | Implemented | Wallet-scoped entry search, pagination, amount/finality filters, and spec-style entry summaries are present. | +| `GET /report/stats` | `camt-crypto-reporting-v1.yaml` | query params | `ReportStatsResponse` | Implemented | Wallet-scoped token totals and grouped breakdowns are present for the current local dataset. | + +## Reference-server extensions + +These routes are real, but they are outside the current root YAML specs and therefore are tracked as server extensions rather than spec conformance: + +- `GET /execution-status/:instructionId` +- `GET /execution-status/uetr/:uetr` +- `GET /finality-receipt/:instructionId` +- `GET /finality-receipt/uetr/:uetr` +- `GET /event-outbox` +- `GET /event-outbox/:eventId` +- `POST /exceptions/investigations` +- `PATCH /exceptions/investigations/:caseId` +- `GET /exceptions/investigations` +- `GET /exceptions/investigations/:caseId` +- `POST /exceptions/returns` +- `PATCH /exceptions/returns/:returnCaseId` +- `GET /exceptions/returns` +- `GET /exceptions/returns/:returnCaseId` +- `POST /webhook-endpoints` +- `GET /webhook-endpoints` +- `GET /webhook-endpoints/:subscriptionId` +- `GET /webhook-endpoints/:subscriptionId/deliveries` +- `GET /webhook-deliveries` +- `GET /webhook-deliveries/:deliveryId` +- `POST /webhook-deliveries/dispatch` +- `GET /reporting/notifications` +- `GET /reporting/notifications/:notificationId` +- `GET /reporting/intraday` +- `GET /reporting/statements` +- `GET /reporting/statements/:statementId` + +The `/reporting/*` routes above remain as compatibility aliases for the earlier +reference-server surface. New conformance work is targeting the root +`/report/*` path family from `camt-crypto-reporting-v1.yaml`. + +## Current conformance focus + +The current conformance work is intentionally limited to the bank-to-VASP wedge already implemented in code: + +- stricter request validation for the spec-covered write routes +- stricter query validation for the spec-covered search and stats routes +- response-shape coverage for the core spec-covered read and search routes +- Tom upstream v1.2 return/reverse/reversal-status alignment using the existing exception-family model as the backing store +- reporting path-family alignment against `camt-crypto-reporting-v1.yaml` +- wallet-scoped notification subscription support on top of the webhook subsystem +- explicit documentation of the current out-of-scope spec surface: + - delegated signing + - bank-side reporting callback endpoint + +Delegated signing, non-EVM flows, richer exception families, bank-side callback +endpoint implementation, `status-request`, and full request idempotency-window +semantics remain outside the current conformance target. The current conformance +layer is hand-authored in code for the implemented wedge rather than generated +directly from the YAML. diff --git a/docs/demo-bank-to-vasp.md b/docs/demo-bank-to-vasp.md new file mode 100644 index 0000000..70c1b8a --- /dev/null +++ b/docs/demo-bank-to-vasp.md @@ -0,0 +1,243 @@ +# Bank-to-VASP Demo + +This is the reviewer-facing demo for the current `pacs.crypto` execution wedge. + +The repo now supports two demo modes: + +- `mock demo` for fast local walkthroughs +- `real-chain Sepolia + USDC demo` for reviewer evidence capture + +It is intentionally narrow: + +- one asset: `USDC` +- one chain family: `EVM` +- one custody model: `FULL_CUSTODY` +- one corridor: `bank -> sending VASP -> on-chain transfer -> receiving VASP` + +## Why This Demo + +This is the shortest path to showing that the repo is no longer just a +standards proposal. + +The demo proves that the stack now supports: + +- Travel Rule submission and beneficiary-side callback +- pre-execution quote and instruction submission +- lifecycle progression through `PENDING -> BROADCAST -> CONFIRMING -> FINAL` +- pacs.002-like status and camt.025-like finality reads +- booked reporting outputs derived from the same payment + +## Reviewer Outcome + +By the end of this walkthrough, a reviewer should be able to see: + +- `pacs.008`-shaped business data survives end to end +- blockchain lifecycle concerns are not stuffed into one oversized instruction response +- polling, webhook/eventing, and reporting all reuse the same identifiers +- the project has moved from mock UI/spec prose into an executable reference stack + +## Sequence + +```mermaid +sequenceDiagram + participant Bank as Instructing Bank + participant TR as Travel Rule API + participant Instr as Instruction API + participant Chain as Mock EVM Adapter + participant RecVASP as Receiving VASP + participant Status as Status/Finality Reads + participant Report as Reporting Reads + + Bank->>TR: POST /travel-rule + TR-->>Bank: Travel Rule record_id + RecVASP->>TR: POST /travel-rule/{recordId}/callback + TR-->>Bank: ACCEPTED callback receipt + + Bank->>Instr: POST /instruction/quote + Instr-->>Bank: quote_id + fee estimate + Bank->>Instr: POST /instruction\n(travel_rule_record_id + quote_id) + Instr-->>Bank: instruction_id + uetr + + Instr->>Chain: simulate broadcast/finality lifecycle + Chain-->>Instr: BROADCAST + Chain-->>Instr: CONFIRMING + Chain-->>Instr: FINAL + + Bank->>Status: GET /execution-status/{instructionId} + Status-->>Bank: FINAL status + history + Bank->>Status: GET /finality-receipt/{instructionId} + Status-->>Bank: tx hash + block reference + finality + + Instr->>Report: derive booked entries + Bank->>Report: GET /reporting/notifications/{notificationId} + Report-->>Bank: camt.054-like credit notification + Bank->>Report: GET /reporting/statements/{statementId} + Report-->>Bank: camt.053-like creditor statement +``` + +## Live Walkthrough + +### 1. Start the reference server + +```bash +cd reference-server +npm install +npm start +``` + +### 2. Open the simulators + +Open these files locally and switch both to `Live API` mode: + +- `travel-rule-simulator-v3.html` +- `instruction-simulator-v1.html` + +Keep the base URL at `http://127.0.0.1:5050`. + +### 3. Show the Travel Rule handshake + +Use the Travel Rule simulator to show: + +- a `POST /travel-rule` submission +- an `ACCEPTED` callback on the same `record_id` + +The point to emphasize: + +- the Travel Rule record is a linked compliance context +- it is not being duplicated into every later surface + +### 4. Show the instruction path + +Use the Instruction simulator to show: + +- `POST /instruction/quote` +- `POST /instruction` +- `GET /execution-status/{instructionId}` +- `GET /finality-receipt/{instructionId}` + +The point to emphasize: + +- the instruction surface remains the command layer +- lifecycle and final settlement proof are exposed through separate reads + +### 5. Show booked reporting + +Still in the Instruction simulator live view, show: + +- the creditor-side booked notification +- the creditor-side statement +- traceability links back to instruction, finality, and Travel Rule objects + +The point to emphasize: + +- reporting is institution-facing booked-entry reporting, not a block explorer + +## Real-Chain Run + +Use this path when you want one reviewer-grade Sepolia proof bundle rather than +the default mock-backed walkthrough. + +### 1. Preflight the wallet + +```bash +cd reference-server +npm run preflight:sepolia +``` + +This verifies: + +- the configured RPC is actually Sepolia +- the private key and configured source address match +- the configured USDC contract has code +- the source wallet has ETH for gas and enough USDC for `REF_SERVER_DEMO_AMOUNT` + (default `1.00`) + +### 2. Start the server in broadcast mode + +```bash +REF_SERVER_CHAIN_ADAPTER=sepolia-usdc \ +REF_SERVER_SEPOLIA_BROADCAST_ENABLED=true \ +npm start +``` + +### 3. Run the canonical funded-wallet demo + +```bash +REF_SERVER_DEMO_RECIPIENT_WALLET=0x... \ +REF_SERVER_DEMO_DEBTOR_WALLET="$REF_SERVER_SEPOLIA_SOURCE_ADDRESS" \ +npm run demo:sepolia +``` + +The runner writes a complete evidence bundle under: + +- `reference-server/data/demo-runs//` + +The runner exits non-zero unless the captured execution status and finality +receipt are both `FINAL`. + +To turn that bundle into a reviewer-ready one-pager: + +```bash +cd reference-server +npm run demo:report -- data/demo-runs/ +``` + +That writes: + +- `reference-server/data/demo-runs//21-reviewer-summary.md` + +The bundle includes: + +- Travel Rule submission and callback +- quote and instruction payloads +- execution-status poll history +- finality receipt +- reporting notifications and statements +- report search and stats views +- summary JSON with tx hash and Sepolia Etherscan URL +- reviewer markdown summary keyed to the same evidence files + +Use that bundle as the basis for the Tom-facing walkthrough once one clean run +has been captured. + +## Sample Payload Pack + +The exact happy-path payload set used for this demo is under +[`docs/demo-samples/happy-path/`](demo-samples/happy-path/). + +Recommended review order: + +1. [01-travel-rule-submit.request.json]() +2. [02-travel-rule-submit.response.json]() +3. [03-travel-rule-callback.request.json]() +4. [04-travel-rule-callback.response.json]() +5. [05-instruction-quote.request.json]() +6. [06-instruction-quote.response.json]() +7. [07-instruction-submit.request.json]() +8. [08-instruction-submit.response.json]() +9. [09-execution-status.final.response.json]() +10. [10-finality-receipt.final.response.json]() +11. [11-reporting-notification.creditor.response.json]() +12. [12-reporting-statement.creditor.response.json]() + +## What To Say + +Use these points, in roughly this order: + +- This repo started as a spec-first proposal and now has an executable reference stack behind the proposal. +- The implementation stays narrow on purpose: `USDC + one EVM family + full custody`. +- The instruction API is no longer overloaded with every downstream concern. +- Status, finality, webhooks, and reporting are separate but linked surfaces. +- The message-family discipline is the point: `pacs.008` is the commercial anchor, not the only object in the system. + +## What Not To Claim + +Do not overstate the current wedge. + +Still intentionally out of scope: + +- delegated signing +- non-EVM chains +- production-chain execution +- deeper exception workflow beyond the first-slice investigation/return APIs +- tokenized assets, CBDC, or DeFi expansion diff --git a/docs/demo-samples/happy-path/01-travel-rule-submit.request.json b/docs/demo-samples/happy-path/01-travel-rule-submit.request.json new file mode 100644 index 0000000..e66e84a --- /dev/null +++ b/docs/demo-samples/happy-path/01-travel-rule-submit.request.json @@ -0,0 +1,53 @@ +{ + "submission_timing": "PRE_TX", + "travel_rule_data": { + "payment_identification": { + "end_to_end_identification": "E2E-DEMO-2026-0001" + }, + "interbank_settlement_amount": { + "amount": "125000.00", + "currency": "USD" + }, + "charge_bearer": "SHAR", + "debtor": { + "name": "Acme Trading GmbH", + "postal_address": { + "country": "DE" + } + }, + "debtor_account": { + "proxy": { + "identification": "0xdebtordemo000000000000000000000000000001" + } + }, + "debtor_agent": { + "name": "Bankhaus Example AG", + "lei": "7245007VX57GR4IUVZ79" + }, + "creditor": { + "name": "Bravo Supplies B.V.", + "postal_address": { + "country": "NL" + } + }, + "creditor_account": { + "proxy": { + "identification": "0xcreditordemo0000000000000000000000000001" + } + }, + "creditor_agent": { + "name": "Kraken Belgium BVBA", + "lei": "635400DUFB71VFOHVB49" + }, + "debtor_wallet_type": "HOSTED", + "creditor_wallet_type": "HOSTED", + "blockchain_settlement": { + "primary_chain_id": "DLID/X9J9XDMTD", + "legs": [ + { + "leg_type": "ORIGINATION" + } + ] + } + } +} diff --git a/docs/demo-samples/happy-path/02-travel-rule-submit.response.json b/docs/demo-samples/happy-path/02-travel-rule-submit.response.json new file mode 100644 index 0000000..6c43027 --- /dev/null +++ b/docs/demo-samples/happy-path/02-travel-rule-submit.response.json @@ -0,0 +1,63 @@ +{ + "record_id": "af814c5f-a44c-40ea-b4c3-b764fb023ab3", + "submitted_at": "2026-04-22T13:48:23.341Z", + "last_updated_at": "2026-04-22T13:48:23.341Z", + "submission_timing": "PRE_TX", + "status": "SUBMITTED", + "travel_rule_data": { + "payment_identification": { + "end_to_end_identification": "E2E-DEMO-2026-0001" + }, + "interbank_settlement_amount": { + "amount": "125000.00", + "currency": "USD" + }, + "charge_bearer": "SHAR", + "debtor": { + "name": "Acme Trading GmbH", + "postal_address": { + "country": "DE" + } + }, + "debtor_account": { + "proxy": { + "identification": "0xdebtordemo000000000000000000000000000001" + } + }, + "debtor_agent": { + "name": "Bankhaus Example AG", + "lei": "7245007VX57GR4IUVZ79" + }, + "creditor": { + "name": "Bravo Supplies B.V.", + "postal_address": { + "country": "NL" + } + }, + "creditor_account": { + "proxy": { + "identification": "0xcreditordemo0000000000000000000000000001" + } + }, + "creditor_agent": { + "name": "Kraken Belgium BVBA", + "lei": "635400DUFB71VFOHVB49" + }, + "debtor_wallet_type": "HOSTED", + "creditor_wallet_type": "HOSTED", + "blockchain_settlement": { + "primary_chain_id": "DLID/X9J9XDMTD", + "legs": [ + { + "leg_type": "ORIGINATION" + } + ] + } + }, + "submitting_vasp": { + "name": "Bankhaus Example AG", + "lei": "7245007VX57GR4IUVZ79" + }, + "callbacks": [], + "correction_of_callback_ref": null +} diff --git a/docs/demo-samples/happy-path/03-travel-rule-callback.request.json b/docs/demo-samples/happy-path/03-travel-rule-callback.request.json new file mode 100644 index 0000000..ddc3903 --- /dev/null +++ b/docs/demo-samples/happy-path/03-travel-rule-callback.request.json @@ -0,0 +1,9 @@ +{ + "callback_status": "ACCEPTED", + "receiving_vasp": { + "name": "Kraken Belgium BVBA", + "lei": "635400DUFB71VFOHVB49" + }, + "callback_timestamp": "2026-04-22T13:48:23.343Z", + "description": "Travel Rule payload accepted for beneficiary-side intake." +} diff --git a/docs/demo-samples/happy-path/04-travel-rule-callback.response.json b/docs/demo-samples/happy-path/04-travel-rule-callback.response.json new file mode 100644 index 0000000..212f376 --- /dev/null +++ b/docs/demo-samples/happy-path/04-travel-rule-callback.response.json @@ -0,0 +1,6 @@ +{ + "record_id": "af814c5f-a44c-40ea-b4c3-b764fb023ab3", + "callback_recorded_at": "2026-04-22T13:48:23.343Z", + "current_status": "ACCEPTED", + "previous_status": "SUBMITTED" +} diff --git a/docs/demo-samples/happy-path/05-instruction-quote.request.json b/docs/demo-samples/happy-path/05-instruction-quote.request.json new file mode 100644 index 0000000..68b6dd1 --- /dev/null +++ b/docs/demo-samples/happy-path/05-instruction-quote.request.json @@ -0,0 +1,10 @@ +{ + "token": { + "token_symbol": "USDC", + "token_dti": "T9B3X8H2K" + }, + "chain_dli": "X9J9XDMTD", + "amount": "125000.00", + "currency": "USD", + "custody_model": "FULL_CUSTODY" +} diff --git a/docs/demo-samples/happy-path/06-instruction-quote.response.json b/docs/demo-samples/happy-path/06-instruction-quote.response.json new file mode 100644 index 0000000..0c47d74 --- /dev/null +++ b/docs/demo-samples/happy-path/06-instruction-quote.response.json @@ -0,0 +1,65 @@ +{ + "quote_id": "79c52f2e-de14-48c6-b975-18b01c1ce8a0", + "valid_until": "2026-04-22T13:53:23.344Z", + "fee_lock_type": "CAPPED", + "fee_estimate": { + "gas_cost_native": { + "amount": "0.001505", + "currency": "ETH" + }, + "gas_cost_fiat": { + "amount": "5.70", + "currency": "USD" + }, + "vasp_service_fee": { + "amount": "24.50", + "currency": "USD" + }, + "ramp_spread_bps": 0, + "slippage_estimate_bps": 7, + "total_cost_fiat": { + "amount": "30.20", + "currency": "USD" + } + }, + "estimated_confirmation_seconds": 144, + "chain_conditions": { + "congestion_level": "MEDIUM", + "current_base_fee_gwei": "19.8", + "average_block_time_seconds": 12 + }, + "adapter_metadata": { + "adapter_id": "mock-evm", + "adapter_mode": "SIMULATED", + "chain_family": "EVM", + "chain_dli": "X9J9XDMTD", + "settlement_model": "PROBABILISTIC_TO_THRESHOLD_FINALITY", + "simulated": true, + "congestion_level": "MEDIUM", + "network_profile": { + "native_currency": "ETH", + "native_usd_price": 3785, + "reference_block_base_number": 22190456 + }, + "lifecycle_policy": { + "ramp_type": "NONE", + "maximum_slippage_rate": "0.0000", + "required_confirmation_depth": 12, + "estimated_confirmation_seconds": 144, + "block_time_seconds": 12, + "confirmation_interval_ms": 250, + "broadcast_delay_ms": 850, + "inclusion_delay_ms": 1800, + "finality_delay_ms": 4800 + }, + "fee_model": { + "gas_limit": 70000, + "base_fee_gwei": "19.8", + "priority_fee_gwei": "1.7", + "service_fee_usd": "24.50", + "ramp_spread_bps": 0, + "slippage_estimate_bps": 7 + } + }, + "created_at": "2026-04-22T13:48:23.344Z" +} diff --git a/docs/demo-samples/happy-path/07-instruction-submit.request.json b/docs/demo-samples/happy-path/07-instruction-submit.request.json new file mode 100644 index 0000000..1a6e4a6 --- /dev/null +++ b/docs/demo-samples/happy-path/07-instruction-submit.request.json @@ -0,0 +1,49 @@ +{ + "payment_identification": { + "end_to_end_identification": "E2E-DEMO-2026-0001", + "quote_id": "79c52f2e-de14-48c6-b975-18b01c1ce8a0" + }, + "charge_bearer": "DEBT", + "debtor": { + "name": "Acme Trading GmbH", + "lei": "529900T8BM49AURSDO55" + }, + "debtor_account": { + "proxy": { + "identification": "0xdebtordemo000000000000000000000000000001" + } + }, + "debtor_agent": { + "name": "Bankhaus Example AG", + "lei": "7245007VX57GR4IUVZ79" + }, + "creditor": { + "name": "Bravo Supplies B.V.", + "lei": "724500QHKL6MVSQQ1Z17" + }, + "creditor_account": { + "proxy": { + "identification": "0xcreditordemo0000000000000000000000000001" + } + }, + "creditor_agent": { + "name": "Kraken Belgium BVBA", + "lei": "635400DUFB71VFOHVB49" + }, + "interbank_settlement_amount": { + "amount": "125000.00", + "currency": "USD" + }, + "remittance_information": { + "unstructured": "Invoice 78452 / Germany to Netherlands treasury settlement" + }, + "blockchain_instruction": { + "token": { + "token_symbol": "USDC", + "token_dti": "T9B3X8H2K" + }, + "chain_dli": "X9J9XDMTD", + "custody_model": "FULL_CUSTODY" + }, + "travel_rule_record_id": "af814c5f-a44c-40ea-b4c3-b764fb023ab3" +} diff --git a/docs/demo-samples/happy-path/08-instruction-submit.response.json b/docs/demo-samples/happy-path/08-instruction-submit.response.json new file mode 100644 index 0000000..abe05fc --- /dev/null +++ b/docs/demo-samples/happy-path/08-instruction-submit.response.json @@ -0,0 +1,62 @@ +{ + "instruction_id": "a6eef166-256b-459e-a8e1-4e525021137e", + "uetr": "433bb2fb-37a8-46cc-b6de-20b1474a7854", + "status": "PENDING", + "custody_model": "FULL_CUSTODY", + "fee_estimate": { + "gas_cost_native": { + "amount": "0.001505", + "currency": "ETH" + }, + "gas_cost_fiat": { + "amount": "5.70", + "currency": "USD" + }, + "vasp_service_fee": { + "amount": "24.50", + "currency": "USD" + }, + "ramp_spread_bps": 0, + "slippage_estimate_bps": 7, + "total_cost_fiat": { + "amount": "30.20", + "currency": "USD" + } + }, + "expiry_date_time": "2026-04-22T14:18:23.344Z", + "debit_timing": "ON_BROADCAST", + "adapter_metadata": { + "adapter_id": "mock-evm", + "adapter_mode": "SIMULATED", + "chain_family": "EVM", + "chain_dli": "X9J9XDMTD", + "settlement_model": "PROBABILISTIC_TO_THRESHOLD_FINALITY", + "simulated": true, + "congestion_level": "MEDIUM", + "network_profile": { + "native_currency": "ETH", + "native_usd_price": 3785, + "reference_block_base_number": 22190456 + }, + "lifecycle_policy": { + "ramp_type": "NONE", + "maximum_slippage_rate": "0.0010", + "required_confirmation_depth": 12, + "estimated_confirmation_seconds": 144, + "block_time_seconds": 12, + "confirmation_interval_ms": 250, + "broadcast_delay_ms": 850, + "inclusion_delay_ms": 1800, + "finality_delay_ms": 4800 + }, + "fee_model": { + "gas_limit": 70000, + "base_fee_gwei": "19.8", + "priority_fee_gwei": "1.7", + "service_fee_usd": "24.50", + "ramp_spread_bps": 0, + "slippage_estimate_bps": 7 + } + }, + "created_at": "2026-04-22T13:48:23.344Z" +} diff --git a/docs/demo-samples/happy-path/09-execution-status.final.response.json b/docs/demo-samples/happy-path/09-execution-status.final.response.json new file mode 100644 index 0000000..d9bee6f --- /dev/null +++ b/docs/demo-samples/happy-path/09-execution-status.final.response.json @@ -0,0 +1,91 @@ +{ + "instruction_id": "a6eef166-256b-459e-a8e1-4e525021137e", + "uetr": "433bb2fb-37a8-46cc-b6de-20b1474a7854", + "end_to_end_identification": "E2E-DEMO-2026-0001", + "travel_rule_record_id": "af814c5f-a44c-40ea-b4c3-b764fb023ab3", + "status": "FINAL", + "status_group": "SETTLED", + "latest_status_at": "2026-04-22T13:48:23.347Z", + "failure_reason": null, + "chain_dli": "X9J9XDMTD", + "token": { + "token_symbol": "USDC", + "token_dti": "T9B3X8H2K" + }, + "transaction_hash": "0x7e29497a68701f463fffc852ca769e4e6d5324eab2d1c296ba522ec20172669e", + "finality_status": "FINAL", + "confirmation_depth": 12, + "required_confirmation_depth": 12, + "transfer_verification": null, + "debit_timing": "ON_BROADCAST", + "expiry_date_time": "2026-04-22T14:18:23.344Z", + "created_at": "2026-04-22T13:48:16.347Z", + "updated_at": "2026-04-22T13:48:23.347Z", + "adapter_metadata": { + "adapter_id": "mock-evm", + "adapter_mode": "SIMULATED", + "chain_family": "EVM", + "chain_dli": "X9J9XDMTD", + "settlement_model": "PROBABILISTIC_TO_THRESHOLD_FINALITY", + "simulated": true, + "congestion_level": "MEDIUM", + "network_profile": { + "native_currency": "ETH", + "native_usd_price": 3785, + "reference_block_base_number": 22190456 + }, + "lifecycle_policy": { + "ramp_type": "NONE", + "maximum_slippage_rate": "0.0010", + "required_confirmation_depth": 12, + "estimated_confirmation_seconds": 144, + "block_time_seconds": 12, + "confirmation_interval_ms": 250, + "broadcast_delay_ms": 850, + "inclusion_delay_ms": 1800, + "finality_delay_ms": 4800 + }, + "fee_model": { + "gas_limit": 70000, + "base_fee_gwei": "19.8", + "priority_fee_gwei": "1.7", + "service_fee_usd": "24.50", + "ramp_spread_bps": 0, + "slippage_estimate_bps": 7 + } + }, + "status_history": [ + { + "sequence": 1, + "event_id": "9dc153f0-97ea-478f-9d17-47df6a60bf42", + "status": "PENDING", + "status_at": "2026-04-22T13:48:23.344Z", + "reason_code": "ACCEPTED_FOR_EXECUTION", + "description": "Instruction accepted and queued for execution." + }, + { + "sequence": 2, + "event_id": "50f246a4-6f3d-4c7c-ac97-bc05f12c0e34", + "status": "BROADCAST", + "status_at": "2026-04-22T13:48:23.345Z", + "reason_code": "BROADCAST_TO_CHAIN", + "description": "Transaction submitted to the blockchain network." + }, + { + "sequence": 3, + "event_id": "b261bf85-b811-4bac-ba8e-5c8c8469a80b", + "status": "CONFIRMING", + "status_at": "2026-04-22T13:48:23.346Z", + "reason_code": "BLOCK_INCLUDED", + "description": "Transaction included in a block and accumulating confirmations." + }, + { + "sequence": 4, + "event_id": "9ee45cac-5fd4-45df-be82-fe1efddb6927", + "status": "FINAL", + "status_at": "2026-04-22T13:48:23.347Z", + "reason_code": "FINALITY_THRESHOLD_REACHED", + "description": "Transaction reached the required finality threshold." + } + ] +} diff --git a/docs/demo-samples/happy-path/10-finality-receipt.final.response.json b/docs/demo-samples/happy-path/10-finality-receipt.final.response.json new file mode 100644 index 0000000..53e81e8 --- /dev/null +++ b/docs/demo-samples/happy-path/10-finality-receipt.final.response.json @@ -0,0 +1,61 @@ +{ + "instruction_id": "a6eef166-256b-459e-a8e1-4e525021137e", + "uetr": "433bb2fb-37a8-46cc-b6de-20b1474a7854", + "end_to_end_identification": "E2E-DEMO-2026-0001", + "travel_rule_record_id": "af814c5f-a44c-40ea-b4c3-b764fb023ab3", + "instruction_status": "FINAL", + "chain_dli": "X9J9XDMTD", + "token": { + "token_symbol": "USDC", + "token_dti": "T9B3X8H2K" + }, + "settlement_amount": { + "amount": "125000.00", + "currency": "USD" + }, + "transaction_hash": "0x7e29497a68701f463fffc852ca769e4e6d5324eab2d1c296ba522ec20172669e", + "broadcast_at": "2026-04-22T13:48:23.345Z", + "included_at": "2026-04-22T13:48:23.346Z", + "block_number": 22274289, + "block_timestamp": "2026-04-22T13:48:18.147Z", + "confirmation_depth": 12, + "required_confirmation_depth": 12, + "finality_status": "FINAL", + "transfer_verification": null, + "observed_at": "2026-04-22T13:48:23.347Z", + "final_at": "2026-04-22T13:48:23.347Z", + "not_applicable_reason": null, + "adapter_metadata": { + "adapter_id": "mock-evm", + "adapter_mode": "SIMULATED", + "chain_family": "EVM", + "chain_dli": "X9J9XDMTD", + "settlement_model": "PROBABILISTIC_TO_THRESHOLD_FINALITY", + "simulated": true, + "congestion_level": "MEDIUM", + "network_profile": { + "native_currency": "ETH", + "native_usd_price": 3785, + "reference_block_base_number": 22190456 + }, + "lifecycle_policy": { + "ramp_type": "NONE", + "maximum_slippage_rate": "0.0010", + "required_confirmation_depth": 12, + "estimated_confirmation_seconds": 144, + "block_time_seconds": 12, + "confirmation_interval_ms": 250, + "broadcast_delay_ms": 850, + "inclusion_delay_ms": 1800, + "finality_delay_ms": 4800 + }, + "fee_model": { + "gas_limit": 70000, + "base_fee_gwei": "19.8", + "priority_fee_gwei": "1.7", + "service_fee_usd": "24.50", + "ramp_spread_bps": 0, + "slippage_estimate_bps": 7 + } + } +} diff --git a/docs/demo-samples/happy-path/11-reporting-notification.creditor.response.json b/docs/demo-samples/happy-path/11-reporting-notification.creditor.response.json new file mode 100644 index 0000000..0007fa3 --- /dev/null +++ b/docs/demo-samples/happy-path/11-reporting-notification.creditor.response.json @@ -0,0 +1,71 @@ +{ + "notification_id": "8774d448-8d1f-463f-b758-cd90e57e5c7b", + "message_family": "camt.054_analogue", + "notification_type": "BOOKED_ENTRY", + "entry_type": "CREDIT", + "account_role": "CREDITOR", + "booking_status": "BOOKED", + "booking_date_time": "2026-04-22T13:48:23.347Z", + "value_date_time": "2026-04-22T13:48:23.347Z", + "instruction_id": "a6eef166-256b-459e-a8e1-4e525021137e", + "uetr": "433bb2fb-37a8-46cc-b6de-20b1474a7854", + "end_to_end_identification": "E2E-DEMO-2026-0001", + "travel_rule_record_id": "af814c5f-a44c-40ea-b4c3-b764fb023ab3", + "status_reference": { + "trigger_status": "FINAL", + "debit_timing": "ON_BROADCAST", + "current_instruction_status": "FINAL" + }, + "party": { + "name": "Bravo Supplies B.V.", + "lei": "724500QHKL6MVSQQ1Z17", + "wallet_address": "0xcreditordemo0000000000000000000000000001" + }, + "counterparty": { + "name": "Acme Trading GmbH", + "lei": "529900T8BM49AURSDO55", + "wallet_address": "0xdebtordemo000000000000000000000000000001" + }, + "servicing_agent": { + "name": "Kraken Belgium BVBA", + "lei": "635400DUFB71VFOHVB49", + "bic": null + }, + "counterparty_agent": { + "name": "Bankhaus Example AG", + "lei": "7245007VX57GR4IUVZ79", + "bic": null + }, + "settlement_amount": { + "amount": "125000.00", + "currency": "USD" + }, + "chain_dli": "X9J9XDMTD", + "token": { + "token_symbol": "USDC", + "token_dti": "T9B3X8H2K" + }, + "transaction_hash": "0x7e29497a68701f463fffc852ca769e4e6d5324eab2d1c296ba522ec20172669e", + "remittance_information": { + "unstructured": "Invoice 78452 / Germany to Netherlands treasury settlement" + }, + "traceability": { + "instruction_id": "a6eef166-256b-459e-a8e1-4e525021137e", + "uetr": "433bb2fb-37a8-46cc-b6de-20b1474a7854", + "end_to_end_identification": "E2E-DEMO-2026-0001", + "travel_rule_record_id": "af814c5f-a44c-40ea-b4c3-b764fb023ab3", + "transaction_hash": "0x7e29497a68701f463fffc852ca769e4e6d5324eab2d1c296ba522ec20172669e", + "account_role": "CREDITOR", + "notification_id": "8774d448-8d1f-463f-b758-cd90e57e5c7b", + "resource_paths": { + "instruction": "/instruction/a6eef166-256b-459e-a8e1-4e525021137e", + "execution_status": "/execution-status/a6eef166-256b-459e-a8e1-4e525021137e", + "finality_receipt": "/finality-receipt/a6eef166-256b-459e-a8e1-4e525021137e", + "reporting_notifications": "/reporting/notifications?instruction_id=a6eef166-256b-459e-a8e1-4e525021137e&account_role=CREDITOR", + "reporting_statements": "/reporting/statements?instruction_id=a6eef166-256b-459e-a8e1-4e525021137e&account_role=CREDITOR", + "reporting_notification": "/reporting/notifications/8774d448-8d1f-463f-b758-cd90e57e5c7b", + "travel_rule_record": "/travel-rule/af814c5f-a44c-40ea-b4c3-b764fb023ab3" + } + }, + "created_at": "2026-04-22T13:48:23.347Z" +} diff --git a/docs/demo-samples/happy-path/12-reporting-statement.creditor.response.json b/docs/demo-samples/happy-path/12-reporting-statement.creditor.response.json new file mode 100644 index 0000000..f3273db --- /dev/null +++ b/docs/demo-samples/happy-path/12-reporting-statement.creditor.response.json @@ -0,0 +1,136 @@ +{ + "statement_id": "db2452bd-62fa-44d5-986e-c5864aa2cf1a", + "statement_key": "{\"instruction_id\":\"a6eef166-256b-459e-a8e1-4e525021137e\",\"account_role\":\"CREDITOR\"}", + "message_family": "camt.053_analogue", + "statement_type": "ACCOUNT_STATEMENT", + "instruction_id": "a6eef166-256b-459e-a8e1-4e525021137e", + "uetr": "433bb2fb-37a8-46cc-b6de-20b1474a7854", + "account_role": "CREDITOR", + "travel_rule_record_id": "af814c5f-a44c-40ea-b4c3-b764fb023ab3", + "statement_date": "2026-04-22", + "period": { + "from": "2026-04-22T13:48:23.347Z", + "to": "2026-04-22T13:48:23.347Z" + }, + "statement_scope": { + "derivation_basis": "BOOKED_NOTIFICATIONS", + "account_role": "CREDITOR", + "source_notification_count": 1, + "source_notification_ids": [ + "8774d448-8d1f-463f-b758-cd90e57e5c7b" + ], + "booking_date_time_from": "2026-04-22T13:48:23.347Z", + "booking_date_time_to": "2026-04-22T13:48:23.347Z" + }, + "party": { + "name": "Bravo Supplies B.V.", + "lei": "724500QHKL6MVSQQ1Z17", + "wallet_address": "0xcreditordemo0000000000000000000000000001" + }, + "counterparty": { + "name": "Acme Trading GmbH", + "lei": "529900T8BM49AURSDO55", + "wallet_address": "0xdebtordemo000000000000000000000000000001" + }, + "chain_dli": "X9J9XDMTD", + "token": { + "token_symbol": "USDC", + "token_dti": "T9B3X8H2K" + }, + "transaction_hash": "0x7e29497a68701f463fffc852ca769e4e6d5324eab2d1c296ba522ec20172669e", + "instruction_context": { + "status": "FINAL", + "finality_status": "FINAL", + "debit_timing": "ON_BROADCAST", + "end_to_end_identification": "E2E-DEMO-2026-0001", + "travel_rule_record_id": "af814c5f-a44c-40ea-b4c3-b764fb023ab3", + "related_notification_count": 1 + }, + "balance_summary": { + "opening_balance": { + "amount": "0", + "currency": "USD", + "credit_debit_indicator": "CRDT" + }, + "closing_balance": { + "amount": "125000", + "currency": "USD", + "credit_debit_indicator": "CRDT" + }, + "available_balance": { + "amount": "125000", + "currency": "USD", + "credit_debit_indicator": "CRDT" + } + }, + "movement_summary": { + "entry_count": 1, + "debit_total": "0", + "credit_total": "125000", + "net_total": "125000", + "net_credit_debit_indicator": "CRDT" + }, + "entries": [ + { + "notification_id": "8774d448-8d1f-463f-b758-cd90e57e5c7b", + "entry_type": "CREDIT", + "account_role": "CREDITOR", + "booking_status": "BOOKED", + "booking_date_time": "2026-04-22T13:48:23.347Z", + "instruction_id": "a6eef166-256b-459e-a8e1-4e525021137e", + "uetr": "433bb2fb-37a8-46cc-b6de-20b1474a7854", + "end_to_end_identification": "E2E-DEMO-2026-0001", + "travel_rule_record_id": "af814c5f-a44c-40ea-b4c3-b764fb023ab3", + "party_name": "Bravo Supplies B.V.", + "counterparty_name": "Acme Trading GmbH", + "wallet_address": "0xcreditordemo0000000000000000000000000001", + "counterparty_wallet_address": "0xdebtordemo000000000000000000000000000001", + "amount": "125000.00", + "currency": "USD", + "token_symbol": "USDC", + "chain_dli": "X9J9XDMTD", + "transaction_hash": "0x7e29497a68701f463fffc852ca769e4e6d5324eab2d1c296ba522ec20172669e", + "traceability": { + "instruction_id": "a6eef166-256b-459e-a8e1-4e525021137e", + "uetr": "433bb2fb-37a8-46cc-b6de-20b1474a7854", + "end_to_end_identification": "E2E-DEMO-2026-0001", + "travel_rule_record_id": "af814c5f-a44c-40ea-b4c3-b764fb023ab3", + "transaction_hash": "0x7e29497a68701f463fffc852ca769e4e6d5324eab2d1c296ba522ec20172669e", + "account_role": "CREDITOR", + "notification_id": "8774d448-8d1f-463f-b758-cd90e57e5c7b", + "resource_paths": { + "instruction": "/instruction/a6eef166-256b-459e-a8e1-4e525021137e", + "execution_status": "/execution-status/a6eef166-256b-459e-a8e1-4e525021137e", + "finality_receipt": "/finality-receipt/a6eef166-256b-459e-a8e1-4e525021137e", + "reporting_notifications": "/reporting/notifications?instruction_id=a6eef166-256b-459e-a8e1-4e525021137e&account_role=CREDITOR", + "reporting_statements": "/reporting/statements?instruction_id=a6eef166-256b-459e-a8e1-4e525021137e&account_role=CREDITOR", + "reporting_notification": "/reporting/notifications/8774d448-8d1f-463f-b758-cd90e57e5c7b", + "travel_rule_record": "/travel-rule/af814c5f-a44c-40ea-b4c3-b764fb023ab3" + } + } + } + ], + "traceability": { + "instruction_id": "a6eef166-256b-459e-a8e1-4e525021137e", + "uetr": "433bb2fb-37a8-46cc-b6de-20b1474a7854", + "end_to_end_identification": "E2E-DEMO-2026-0001", + "travel_rule_record_id": "af814c5f-a44c-40ea-b4c3-b764fb023ab3", + "transaction_hash": "0x7e29497a68701f463fffc852ca769e4e6d5324eab2d1c296ba522ec20172669e", + "account_role": "CREDITOR", + "statement_id": "db2452bd-62fa-44d5-986e-c5864aa2cf1a", + "source_notification_ids": [ + "8774d448-8d1f-463f-b758-cd90e57e5c7b" + ], + "resource_paths": { + "instruction": "/instruction/a6eef166-256b-459e-a8e1-4e525021137e", + "execution_status": "/execution-status/a6eef166-256b-459e-a8e1-4e525021137e", + "finality_receipt": "/finality-receipt/a6eef166-256b-459e-a8e1-4e525021137e", + "reporting_notifications": "/reporting/notifications?instruction_id=a6eef166-256b-459e-a8e1-4e525021137e&account_role=CREDITOR", + "reporting_statements": "/reporting/statements?instruction_id=a6eef166-256b-459e-a8e1-4e525021137e&account_role=CREDITOR", + "reporting_statement": "/reporting/statements/db2452bd-62fa-44d5-986e-c5864aa2cf1a", + "travel_rule_record": "/travel-rule/af814c5f-a44c-40ea-b4c3-b764fb023ab3" + } + }, + "created_at": "2026-04-22T13:48:23.347Z", + "updated_at": "2026-04-22T13:48:23.347Z" +} diff --git a/docs/demo-samples/happy-path/README.md b/docs/demo-samples/happy-path/README.md new file mode 100644 index 0000000..323bc34 --- /dev/null +++ b/docs/demo-samples/happy-path/README.md @@ -0,0 +1,35 @@ +# Happy-Path Sample Pack + +This directory contains the canonical sample payloads for the current reviewer +demo: + +- `USDC` +- `EVM` +- `FULL_CUSTODY` +- Travel Rule accepted before instruction execution +- lifecycle progressed through `PENDING -> BROADCAST -> CONFIRMING -> FINAL` + +These payloads were generated from the current local reference implementation on +`2026-04-22`. + +## Included Files + +- `01-travel-rule-submit.request.json` +- `02-travel-rule-submit.response.json` +- `03-travel-rule-callback.request.json` +- `04-travel-rule-callback.response.json` +- `05-instruction-quote.request.json` +- `06-instruction-quote.response.json` +- `07-instruction-submit.request.json` +- `08-instruction-submit.response.json` +- `09-execution-status.final.response.json` +- `10-finality-receipt.final.response.json` +- `11-reporting-notification.creditor.response.json` +- `12-reporting-statement.creditor.response.json` + +## Notes + +- IDs and timestamps come from one generated run and are intentionally kept + consistent across the pack. +- The reporting samples use the creditor-side objects to keep the pack compact. +- The live server also exposes the symmetric debtor-side reporting objects. diff --git a/docs/exception-family.md b/docs/exception-family.md new file mode 100644 index 0000000..f874307 --- /dev/null +++ b/docs/exception-family.md @@ -0,0 +1,369 @@ +# Exception-Family Design + +This document defines the exception-family boundary for the current +`pacs.crypto` wedge and now doubles as the design note for the implemented +first slice. + +The current runtime covers `investigation_case` and `return_case` read/write +surfaces. It does not yet implement richer bilateral cancellation or full +remediation workflow semantics. + +## Purpose + +The current stack already has terminal and exceptional statuses: + +- `CANCELLED` +- `EXPIRED` +- `FAILED` +- `SLIPPAGE_EXCEEDED` +- `RAMP_FAILED` + +Those are necessary, but they are not a full exception-family. + +The missing design question is: + +- when does an operational exception remain part of the existing instruction + lifecycle +- and when does it become a new cross-party exception or remediation object + +## Core Rule + +Blockchain irreversibility must remain explicit. + +That means: + +- pre-broadcast cancellation is a workflow control decision +- post-settlement remediation is a new economic action, not a hidden reversal of + the original transfer +- investigations are case-management objects, not overloaded status fields + +## Decision Summary + +### Stays in current APIs + +These behaviors stay where they are: + +- `DELETE /instruction/{instructionId}` for pre-broadcast cancellation only +- terminal execution outcomes on `execution-status` +- finality proof on `finality-receipt` +- Travel Rule correction on the existing Travel Rule record + +These do **not** require a new family for the current wedge. + +### Becomes a dedicated exception family + +These behaviors should become explicit exception-family objects: + +- post-settlement return or compensating transfer +- cross-party cancellation negotiation once there is real bilateral workflow +- investigation and case handling across instruction, finality, reporting, and + Travel Rule references + +## Exception Taxonomy + +### 1. Pre-broadcast cancellation + +Definition: + +- the instructing party withdraws the payment before on-chain broadcast + +Current system behavior: + +- remains in the current instruction API +- terminal status is `CANCELLED` +- no exception-family object is required for the current wedge + +Reason: + +- this is still command-surface behavior, not post-fact remediation + +### 2. Pre-execution expiry or bounded rejection + +Definition: + +- the payment never becomes an on-chain transfer because it expires or violates + a bounded execution constraint + +Current system behavior: + +- remains in the current lifecycle surfaces +- status is `EXPIRED`, `SLIPPAGE_EXCEEDED`, or `RAMP_FAILED` + +Reason: + +- this is still execution-state semantics, not a separate exception workflow + +### 3. Post-broadcast execution failure + +Definition: + +- the instruction progressed beyond pure acceptance but did not cleanly reach + intended settlement + +Current system behavior: + +- remains visible through `execution-status` and `finality-receipt` +- generic undisclosed failure remains `FAILED` + +Future family impact: + +- only escalates into an investigation case when cross-party follow-up is + needed + +### 4. Post-settlement remediation + +Definition: + +- the original payment is already economically final or treated as final enough + that remediation requires a new transfer or off-chain refund + +This is the first true exception-family object. + +Design rule: + +- do not represent this as the original payment changing from `FINAL` to some + synthetic “reversed” status +- represent it as a linked remediation record + +Recommended message analogue: + +- `pacs.004`-like return or compensation object + +Tom v1.2 instruction façade: + +- `POST /instruction/{instructionId}/return` is implemented as a public v1.2 path + backed by this return-case model. It creates a real compensating instruction + and keeps the original instruction `FINAL`. +- The response follows Tom's `CompensatingInstructionResponse` shape, while the + internal exception-family record retains traceability and operator workflow + fields. + +### 5. Investigation and dispute handling + +Definition: + +- an operator needs structured follow-up without altering the original payment + object + +Examples: + +- beneficiary credit query after chain finality +- mismatch between booked reporting and beneficiary handling +- Travel Rule dispute after an accepted record +- operational query around a failed or ambiguous transfer + +Recommended message analogue: + +- `camt.029`-like investigation case + +## Recommended Family Boundaries + +### Keep the current command surface narrow + +The current instruction API should continue to own: + +- quote +- submit +- get current instruction state +- pre-broadcast cancel + +It should **not** become the container for: + +- returns +- dispute narratives +- bilateral cancellation negotiation after execution has started + +### Add a dedicated exception family + +Current first-slice runtime: + +- `return_case` (`pacs.004` analogue), including Tom v1.2 `/return` + requests backed by real compensating instructions +- `reversal` request records (`pacs.007`-like Tom v1.2 `/reverse` façade) stored + as exception-family cases while the original on-chain instruction remains + final +- `investigation_case` (`camt.029` analogue) +- optional later `cancellation_case` (`camt.056` / `057` / `058` analogue), + including any broader `status-request` surface if Tom's direction calls for it + +## Proposed Object Model + +### Shared exception identifiers + +Every exception-family object should carry: + +- `exception_case_id` +- `exception_type` +- `status` +- `opened_at` +- `updated_at` +- `related_instruction_id` +- `related_uetr` +- `related_travel_rule_record_id` +- `related_transaction_hash` +- `opened_by` +- `counterparty` +- `reason_code` +- `narrative` + +This preserves the same traceability discipline already used across lifecycle +and reporting surfaces. + +### Return case + +Recommended fields: + +- `return_case_id` +- `return_type` +- `original_instruction_id` +- `original_transaction_hash` +- `return_method` +- `return_amount` +- `return_asset` +- `return_status` +- `compensating_instruction_id` when remediation itself is executed through the + stack +- `off_chain_reference` when remediation happens outside the chain flow + +Recommended `return_method` values: + +- `ON_CHAIN_COMPENSATING_TRANSFER` +- `OFF_CHAIN_REFUND` +- `MANUAL_FIAT_REMEDIATION` + +### Investigation case + +Recommended fields: + +- `investigation_case_id` +- `case_type` +- `case_status` +- `priority` +- `requires_counterparty_action` +- `resolution_type` +- `resolution_summary` +- `linked_return_case_id` where relevant + +Recommended `case_type` values: + +- `STATUS_QUERY` +- `BENEFICIARY_CREDIT_QUERY` +- `TRAVEL_RULE_DISPUTE` +- `RETURN_REQUEST` +- `SETTLEMENT_DISCREPANCY` + +### Cancellation case + +This should remain a later addition only if the stack grows beyond the current +single-orchestrator command model. + +If later implemented, it should cover: + +- asynchronous bilateral cancellation request +- acceptance or rejection by the counterparty +- lapse/expiry of the cancellation request + +For the current wedge, `DELETE /instruction/{instructionId}` remains enough. + +## Status Rules + +### Original payment stays authoritative + +The original instruction should keep its real terminal outcome: + +- `CANCELLED` +- `EXPIRED` +- `FAILED` +- `FINAL` + +The exception-family object links to it; it does not overwrite it. + +### Return cases do not rewrite finality + +If a payment has reached `FINAL`, then: + +- `execution-status` remains `FINAL` +- `finality-receipt` remains `FINAL` +- any remediation is represented as a separate return or compensating object + +This is the single most important design rule in the family. + +## Current First-Slice Endpoints + +Implemented now: + +- `POST /instruction/{instructionId}/return` (Tom v1.2 façade over return cases) +- `POST /instruction/{instructionId}/reverse` (Tom v1.2 reversal request façade) +- `GET /instruction/{instructionId}/reversal-status` (Tom v1.2 reversal status read) +- `POST /exceptions/returns` +- `GET /exceptions/returns/:returnCaseId` +- `GET /exceptions/returns` +- `PATCH /exceptions/returns/:returnCaseId` +- `POST /exceptions/investigations` +- `GET /exceptions/investigations/:caseId` +- `GET /exceptions/investigations` +- `PATCH /exceptions/investigations/:caseId` + +Hold for later: + +- `POST /exceptions/cancellation-requests` +- `GET /exceptions/cancellation-requests/:caseId` + +## Eventing Rule + +The same push/poll discipline should apply here as elsewhere. + +Current implemented event families: + +- `return_case.updated` +- `investigation_case.updated` + +Optional later event family: + +- `cancellation_case.updated` + +For each event: + +- the push payload should equal the canonical polling object +- the transport envelope should add delivery metadata only + +## Implementation Order + +The order used for the first slice is: + +1. `investigation_case` +2. `return_case` +3. `cancellation_case` only if bilateral orchestration really demands it + +Current status: + +- `investigation_case`: implemented and now enforcing workflow transitions plus operator metadata +- `return_case`: implemented and now enforcing method-specific settlement evidence plus reporting-linkage fields +- `cancellation_case`: deferred + +Reason: + +- investigations are the safest addition because they preserve current payment + objects and let operators track disputes without inventing fake reversals +- returns come next because they require remediation semantics but still fit the + existing traceability model +- bilateral cancellation is the most workflow-heavy and least necessary for the + current wedge + +## Next Follow-Ups + +The next questions after the first slice are: + +1. Decide whether reviewer-facing demo material should include one explicit exception flow after the Sepolia evidence path lands. +2. Decide whether compensating-transfer returns should eventually require a completed linked instruction rather than just a stored reference. +3. Decide whether bilateral cancellation is actually needed before adding a + `cancellation_case` family. + +## Non-Goals + +This family should not: + +- invent a fake chain reversal +- leak sanctions-specific internal reasoning +- replace `execution-status` or `finality-receipt` +- duplicate the full Travel Rule payload into every case object diff --git a/docs/reference-stack-plan.md b/docs/reference-stack-plan.md new file mode 100644 index 0000000..c735722 --- /dev/null +++ b/docs/reference-stack-plan.md @@ -0,0 +1,196 @@ +# pacs.crypto Reference Stack Plan + +This document captures the original pivot from a specs-only repo to an executable reference stack. + +The active program-of-record now lives in: + +- [Roadmap](roadmap.md) +- [Backlog](backlog.md) + +## Objective + +Turn this repo from a strong proposal into a credible reference implementation for one serious bank-to-VASP payment flow. + +The goal is not to publish more standards text. The goal is to prove that the existing standards can survive real API behavior, real validation, real state transitions, and real blockchain settlement mechanics. + +## Recommended Wedge + +Build a narrow "bank instructs VASP" reference stack with: + +- one asset: USDC +- one chain family: EVM +- one corridor: bank -> sending VASP -> on-chain transfer -> receiving VASP +- linked Travel Rule record + remittance information + instruction lifecycle +- real API server, real persistence, real contract tests +- mock compliance decisions first, chain integration second + +This is the fastest path to something that is both technically credible and strategically impressive. + +## What To Build First + +### In scope for v0 + +- `POST /travel-rule` +- `POST /travel-rule/{recordId}/callback` +- `PUT /travel-rule/{recordId}` +- `GET /travel-rule/{recordId}` +- `GET /travel-rule/search` +- `GET /travel-rule/stats` +- `POST /instruction/quote` +- `POST /instruction` +- `GET /instruction/{instructionId}` + +### Explicitly out of scope for v0 + +- multi-chain support +- non-EVM signing flows +- delegated signing +- production PKI and counterparty discovery +- real sanctions screening +- real KYC systems +- network-wide reconciliation +- AI agent submission flows + +The first win is not breadth. It is execution quality. + +## Technical Position + +### Stack + +- TypeScript +- Fastify +- OpenAPI-driven request/response validation +- SQLite for local demo persistence +- in-process job scheduler for lifecycle simulation +- chain adapter interface with a mock EVM adapter first + +### Why this stack + +- TypeScript + Fastify is fast to stand up and easy to keep aligned with OpenAPI. +- SQLite is enough for a serious local reference stack and avoids premature ops work. +- A chain adapter abstraction lets the first version run entirely mocked, then switch to testnet without reworking the API layer. + +## Phases + +### Phase 1 - Reposition the repo + +Deliverables: + +- a tightened README framing the repo as an interoperability stack, not just a family of proposals +- this implementation plan +- a backlog of spec hardening issues +- a target repo layout: + - `specs/` + - `simulators/` + - `reference-server/` + - `conformance/` + - `docs/` + +Success criteria: + +- the repo clearly states the primary use case +- the first implementation target is obvious +- non-goals are explicit + +### Phase 2 - Reference server skeleton + +Deliverables: + +- runnable API server +- persistence models for Travel Rule records, callbacks, quotes, and instructions +- endpoint skeletons for the v0 scope +- state machine for Travel Rule and instruction lifecycle +- fixtures for canonical scenarios + +Success criteria: + +- a user can start the server and execute the end-to-end happy path locally +- the API returns real stored data, not simulator-only fabricated responses + +### Phase 3 - Conformance layer + +Deliverables: + +- contract tests for the supported endpoints +- example payload fixtures +- schema validation tied to the OpenAPI files +- error/status matrix for implemented flows + +Success criteria: + +- the repo can prove which parts of the spec are implemented +- examples and runtime behavior do not drift + +### Phase 4 - Chain realism + +Deliverables: + +- EVM transaction lifecycle adapter +- fee estimate model +- broadcast simulation moving to testnet integration +- tx hash, confirmation depth, and finality transitions +- webhook/status update mechanism + +Success criteria: + +- instruction status changes reflect believable chain behavior +- the API can demonstrate quote -> instruction -> broadcast -> confirming -> final + +### Phase 5 - Demo package + +Deliverables: + +- one polished demo scenario +- sequence diagrams +- architecture note describing what changed from the original proposal +- short explanation of where blockchain reality forced sharper design choices + +Success criteria: + +- a reviewer can understand the value in under ten minutes +- the implementation feels like a serious reference stack, not a UI mock + +## Spec Hardening Before Or During Implementation + +These should be addressed early because they affect credibility: + +- fix lifecycle contradictions in the Travel Rule callback flow +- fix the misplaced health route +- tighten instruction failure semantics around undisclosed `FAILED` cases +- define a minimal webhook contract for instruction status updates +- convert important prose-only conditions into enforceable validation rules +- make simulator behavior match normative schema behavior + +## Demo Story To Optimize For + +The best demo is: + +1. Bank submits Travel Rule record with remittance data. +2. Receiving VASP acknowledges data quality via callback. +3. Bank requests quote for a USDC transfer. +4. Bank submits payment instruction referencing the Travel Rule record. +5. VASP broadcasts on-chain. +6. API progresses from pending to confirming to final. +7. User can inspect both compliance record and settlement result from one coherent stack. + +If this works cleanly, the project stops being theoretical. + +## Immediate Build Order + +1. Create `reference-server/` and choose the server/tooling baseline. +2. Implement Travel Rule state model and storage. +3. Implement instruction quote + submission + status model. +4. Back the HTML simulators with the real server. +5. Add contract tests. +6. Add mock chain adapter. +7. Replace mock chain adapter with testnet integration. + +## What Will Actually Impress A Standards Expert + +- executable behavior, not more prose +- precise lifecycle handling +- traceability from spec to running system +- honest scoping +- documented reasons where implementation forced spec refinement + +The strongest position is: this started as a proposal, and now it behaves like a system. diff --git a/docs/roadmap.md b/docs/roadmap.md new file mode 100644 index 0000000..cc06f38 --- /dev/null +++ b/docs/roadmap.md @@ -0,0 +1,245 @@ +# pacs.crypto Roadmap + +## Purpose + +This is the active post-wedge roadmap for `pacs.crypto`. + +The first execution wedge is now complete enough that the next phase is no +longer about proving the mock reference stack. It is about hardening the +execution path before funded use, then turning the same stack into a +real-chain, reviewer-credible execution system without widening the API family +prematurely, while tightening conformance against the now-added root camt +reporting specification. + +## Current Defaults + +These defaults are locked for the next phase: + +- first real execution target: `Ethereum Sepolia` +- first real asset: `USDC on Sepolia` +- first real execution mode: `FULL_CUSTODY` +- primary audience: `Tom-facing reviewer demo` +- implementation seam: chain adapter internals first, route contracts later only if unavoidable + +## Baseline As Of April 2026 + +### Implemented now + +- root-level YAML specifications for Travel Rule, instruction submission, and camt reporting +- standalone HTML simulators with `Demo` and `Live API` modes +- reference server with persisted Travel Rule, quote, instruction, status, finality, webhook, reporting, and first-slice exception data +- pacs.002-like `execution-status` read surface +- camt.025-like `finality-receipt` read surface +- outbox-backed webhook registration and signed delivery attempts +- reporting family first slice: + - `reporting/notifications` (`camt.054` analogue) + - `reporting/intraday` (`camt.052` analogue) + - `reporting/statements` (`camt.053` analogue) + - root-spec `/report/*` path family for query, notification lookup, entry search, and stats +- exception family first slice: + - `exceptions/investigations` (`camt.029` analogue) + - `exceptions/returns` (`pacs.004` analogue) + +### Still mocked or partial + +- chain lifecycle has an opt-in Sepolia adapter, but funded execution is blocked + until the execution-safety review findings are remediated +- the current reviewer demo is still mock-backed even though the stack itself is executable +- delegated signing remains intentionally unimplemented +- broader exception workflow is still shallow compared with real operator remediation + +### Explicitly deferred + +- non-EVM chains +- tokenized assets +- CBDC +- regulated DeFi +- agent-driven flows + +## Roadmap + +### Phase A0 - Execution safety and evidence hardening +Target window: Q2 2026 + +Objective: +Close the review findings that make funded Sepolia execution unsafe or +misleading before any live transfer is run for reviewer evidence. + +Deliverables: + +- single-writer broadcast path so real transfers cannot be submitted by + `GET`, search, reporting, duplicate-check, or list flows +- idempotent execution guard that prevents duplicate transfers under concurrent + polling +- Sepolia USDC corridor enforcement before transfer: debtor/source wallet, + Sepolia DLI, USDC token identity, and USD settlement currency +- ERC-20 `Transfer` log verification before finality receipt claims correct + settlement +- reporting state upgrade so debtor-side pending debit entries become booked + after final settlement +- unsigned reporting balance amounts with credit/debit indicators +- hardened preflight and demo evidence generation that reject failed or + non-final runs as reviewer proof +- corrected USDC token identifiers across demo tooling and sample evidence + +Success criteria: + +- no read-side route can broadcast or duplicate a real transaction +- finality evidence proves token contract, sender, recipient, and amount from + chain logs +- reporting statements include finalized debtor and creditor booked movement +- preflight fails when the configured wallet cannot execute the demo amount +- reviewer evidence is generated only for runs where execution status and + finality are both `FINAL` + +Current status: + +- implemented: funded Sepolia use is gated by explicit submit-only broadcast, + concurrency/idempotency protection, corridor checks, ERC-20 transfer-log + verification, reporting finality upgrade, unsigned balance evidence, and + demo/preflight failure gates +- remaining: validate with one controlled funded Sepolia run in Phase A + +### Phase A - Real Sepolia execution +Target window: Q2 to Q3 2026 +Depends on: Phase A0 + +Objective: +Replace the mocked EVM lifecycle with real Sepolia execution while preserving +the current public API family. + +Deliverables: + +- real `Sepolia USDC` adapter behind the existing chain-adapter seam +- real transaction submission and tracking for the `FULL_CUSTODY` path +- real tx hash, confirmation depth, and finality receipt population through the current read surfaces +- mock adapter retained as a non-default fallback path for local demo/testing + +Success criteria: + +- one instruction can progress from submit to real Sepolia tx hash without route redesign +- `execution-status` and `finality-receipt` remain the canonical read models +- reporting, webhooks, and exceptions keep using the same identifiers +- no `GET`, search, or reporting path can submit or duplicate a real transaction + +Current status: + +- in progress: the `sepolia-usdc` adapter is implemented behind the existing adapter seam, read-only mode is available without private key material, and broadcast mode is environment-gated +- in progress: the happy-path `BROADCAST -> CONFIRMING -> FINAL` Sepolia lifecycle is now covered in automated tests with injected provider/signer stubs, including reporting linkage on the same instruction identifiers +- in progress: preflight and demo-run scripts now exist to validate wallet readiness and capture a real-chain evidence bundle once credentials are present +- ready: funded Sepolia execution awaits configured RPC, private key, source + wallet funding, and recipient wallet +- remaining: run a funded-wallet Sepolia transaction and capture the resulting tx hash, confirmations, and finality receipt for the reviewer demo + +### Phase B - Spec 3 reporting alignment +Target window: Q2 to Q3 2026 + +Objective: +Bring the executable reporting runtime closer to `camt-crypto-reporting-v1.yaml` +without widening the execution corridor. + +Deliverables: + +- expose the root `/report/*` path family alongside the legacy `/reporting/*` aliases +- keep entry search, stats, and notification subscription wallet-scoped and test-covered +- keep documenting the remaining gap between the root reporting flows and bank-side callback scope + +Success criteria: + +- the reference server can be demoed and reviewed against the root reporting spec without relying on extension-only paths +- reporting search and stats read models line up with the reviewer narrative and traceability story + +Current status: + +- in progress: `/report/query`, `/report/intraday`, `/report/statement`, `/report/notification/{notificationId}`, `/report/search`, and `/report/stats` now exist in the reference server +- in progress: the root `GET /report/*` pull routes now return camt.052 / camt.053 / camt.054-style wrappers on top of the existing reporting records without breaking the legacy `/reporting/*` aliases +- in progress: `/report/query` notification subscriptions now deliver raw camt.054-style notification bodies, while generic `/webhook-endpoints` subscriptions keep the event envelope for operator observability +- in progress: `STATEMENT + callback_url` now queues a raw camt.053-style statement callback through the retrying delivery engine +- remaining: keep the bank-side callback boundary explicit and avoid over-expanding reporting before the real-chain demo + +### Phase C - Reviewer demo with real chain evidence +Target window: Q3 2026 +Depends on: Phase A0 and Phase A + +Objective: +Turn the reviewer package into a real-chain proof point rather than a strong +mock narrative. + +Deliverables: + +- one canonical bank-to-VASP walkthrough backed by a real Sepolia transaction +- real-chain sample payload pack with tx hash, confirmations, and finality receipt +- docs and simulator guidance that distinguish `mock demo` from `real-chain demo` + +Success criteria: + +- a reviewer can inspect one live scenario with real chain evidence in under ten minutes +- the demo strengthens the standards story instead of turning into a generic crypto showcase +- reviewer evidence is generated only from `FINAL` runs with verified transfer logs + +Current status: + +- in progress: preflight and demo-run scripts exist for the funded Sepolia path +- in progress: reviewer-summary generation is scripted so a captured run can be turned into a concise markdown evidence pack immediately after execution +- blocked: the first funded evidence bundle waits for Phase A0 and Phase A +- remaining: capture one funded Sepolia run and freeze it as the canonical reviewer sample path + +### Phase D - Exception handling deepening +Target window: Q3 to Q4 2026 + +Objective: +Make the current exception-family first slice strong enough for real operator +follow-up on real-chain flows. + +Deliverables: + +- richer `investigation_case` transitions and operator workflow +- richer `return_case` remediation semantics across off-chain refund and compensating-transfer patterns +- stronger linkage from exception records to real-chain evidence, finality, and reporting consequences + +Success criteria: + +- real-chain operational follow-up can be tracked without mutating original `FINAL` payments +- exception-family objects remain distinct from execution-status and finality reads + +Current status: + +- in progress: investigation cases now enforce lifecycle transitions, closure rules, and operator-workflow fields such as owner, team, due date, and counterparty reference +- in progress: return cases now distinguish on-chain compensating-transfer settlement evidence from off-chain/manual remediation evidence +- in progress: exception cases can now point to specific reporting notifications and statements for the same underlying instruction + +### Phase E - Delegated signing +Target window: Q4 2026 to Q1 2027 + +Objective: +Add the next real bank/VASP differentiator without widening the corridor. + +Deliverables: + +- delegated-signing support on the existing instruction family +- unsigned transaction return plus signed transaction resubmission for the Sepolia wedge +- conformance and demo coverage for the delegated path + +Success criteria: + +- the same corridor supports both `FULL_CUSTODY` and delegated signing +- delegated signing uses the existing family boundaries rather than a new execution model + +### Phase F - Broader expansion +Target window: after Q1 2027 + +Objective: +Expand only after the Sepolia-backed wedge is credible. + +Candidate areas: + +- non-EVM chains +- tokenized assets +- CBDC +- regulated DeFi +- agent-driven flows + +Success criteria: + +- the current stack remains narrow and defensible until the real-chain wedge is proven +- broader family growth does not displace the Sepolia execution program diff --git a/docs/session-handoff.md b/docs/session-handoff.md new file mode 100644 index 0000000..13bc91e --- /dev/null +++ b/docs/session-handoff.md @@ -0,0 +1,193 @@ +# Session Handoff + +This document captures the current working state of `pacs.crypto` so a fresh +Codex session can resume without relying on prior chat history. + +## Repo State + +As of `2026-04-24`: + +- repo root: `/Users/Raafet/Projects/codex_test/PACS_CRYPTO` +- active branch: `main` +- remote target: `origin` = `Raafet57/pacs.crypto` +- intended Git identity: + - `Raafet57` + - `Raafet57@users.noreply.github.com` +- the current working batch is staged but not yet committed +- the previous session could not run `git commit` or `git push` because the + Codex approval/write limit blocked Git writes inside `.git/` + +## Validation Status + +Current validation before commit: + +- `npm test` in `reference-server/`: `44/44` passing +- `git diff --check`: clean + +## What Is In The Current Batch + +### 1. Spec 3 reporting alignment + +Implemented and tightened the root reporting family: + +- `/report/query` +- `/report/intraday` +- `/report/statement` +- `/report/notification/{notificationId}` +- `/report/search` +- `/report/stats` + +Behavior added in this batch: + +- camt-style wrappers for intraday, statement, and notification reads +- raw `BlockchainNotification` delivery for `POST /report/query` + notification subscriptions +- async statement delivery via `callback_url` through the retrying webhook + engine +- compatibility aliases under legacy `/reporting/*` remain in place + +Primary files: + +- `reference-server/src/db.js` +- `reference-server/src/routes/reporting-routes.js` +- `reference-server/src/validators.js` +- `docs/conformance.md` +- `docs/spec-hardening.md` +- `docs/webhook-delivery.md` +- `reference-server/README.md` + +### 2. Exception workflow deepening + +Extended the first-slice exception runtime: + +- stronger `investigation_case` lifecycle rules +- stronger `return_case` lifecycle and settlement evidence rules +- explicit linkage from exceptions to reporting notifications/statements for + the same instruction +- operator workflow fields such as owner, team, due date, and counterparty + reference + +Primary files: + +- `reference-server/src/db.js` +- `reference-server/src/routes/exception-routes.js` +- `reference-server/src/validators.js` +- `docs/exception-family.md` + +### 3. Sepolia demo tooling + +Added operational scripts for the real-chain demo path: + +- `reference-server/scripts/sepolia-preflight.mjs` +- `reference-server/scripts/run-sepolia-demo.mjs` +- `reference-server/scripts/render-demo-evidence.mjs` + +Package scripts added: + +- `npm run preflight:sepolia` +- `npm run demo:sepolia` +- `npm run demo:report` + +Supporting changes: + +- `.gitignore` excludes local demo-run artifacts +- `docs/demo-bank-to-vasp.md` and `reference-server/README.md` now explain the + funded-wallet flow and reviewer bundle generation + +### 4. Sepolia adapter hardening + +The `sepolia-usdc` adapter now: + +- fails safely when broadcast mode is enabled but configuration is incomplete +- fails safely when the configured RPC is not actually Sepolia +- exposes more detailed adapter metadata for the testnet path +- supports injected wallet/contract factories for test coverage of broadcast + behavior without a funded wallet in CI + +Primary file: + +- `reference-server/src/chain/sepolia-usdc-adapter.js` + +### 5. New Sepolia happy-path lifecycle coverage + +The test suite now covers the real-chain lifecycle shape without changing the +public API: + +- `PENDING -> BROADCAST -> CONFIRMING -> FINAL` +- finality receipt linkage +- reporting linkage on the same instruction identifiers + +Primary file: + +- `reference-server/test/app.test.js` + +This does not replace the need for a funded Sepolia run. It reduces the +remaining `P0` gap to actual live-chain evidence capture. + +## Key Files In The Batch + +- `.gitignore` +- `docs/backlog.md` +- `docs/conformance.md` +- `docs/demo-bank-to-vasp.md` +- `docs/exception-family.md` +- `docs/roadmap.md` +- `docs/spec-hardening.md` +- `docs/webhook-delivery.md` +- `reference-server/README.md` +- `reference-server/package.json` +- `reference-server/scripts/render-demo-evidence.mjs` +- `reference-server/scripts/run-sepolia-demo.mjs` +- `reference-server/scripts/sepolia-preflight.mjs` +- `reference-server/src/chain/sepolia-usdc-adapter.js` +- `reference-server/src/db.js` +- `reference-server/src/routes/exception-routes.js` +- `reference-server/src/routes/reporting-routes.js` +- `reference-server/src/validators.js` +- `reference-server/test/app.test.js` + +## Immediate Next Action + +Create one commit for the current batch and push it. + +Suggested commit message: + +`feat(reference-stack): deepen reporting and sepolia demo tooling` + +If committing manually in a normal shell: + +```bash +cd /Users/Raafet/Projects/codex_test/PACS_CRYPTO + +GIT_AUTHOR_NAME="Raafet57" \ +GIT_AUTHOR_EMAIL="Raafet57@users.noreply.github.com" \ +GIT_COMMITTER_NAME="Raafet57" \ +GIT_COMMITTER_EMAIL="Raafet57@users.noreply.github.com" \ +git commit -m "feat(reference-stack): deepen reporting and sepolia demo tooling" + +git push origin main +``` + +## Next Engineering Priority + +After commit/push, continue with `P0`: + +- run the funded Sepolia path and capture one real evidence bundle under + `reference-server/data/demo-runs//` +- generate `21-reviewer-summary.md` with `npm run demo:report` only after the + run reaches `FINAL` execution and `FINAL` finality + +## Main Remaining Blocker + +The real-chain evidence run still requires actual Sepolia configuration and +funding. In-session, the following were not available: + +- `REF_SERVER_SEPOLIA_RPC_URL` +- `REF_SERVER_SEPOLIA_PRIVATE_KEY` +- `REF_SERVER_SEPOLIA_USDC_CONTRACT_ADDRESS` +- `REF_SERVER_SEPOLIA_SOURCE_ADDRESS` +- `REF_SERVER_DEMO_RECIPIENT_WALLET` + +Until those are present, the strongest remaining credibility jump is still +pending: one captured Sepolia transaction hash, confirmation sequence, finality +receipt, and reviewer evidence bundle. diff --git a/docs/spec-hardening.md b/docs/spec-hardening.md new file mode 100644 index 0000000..d5151b6 --- /dev/null +++ b/docs/spec-hardening.md @@ -0,0 +1,240 @@ +# pacs.crypto Spec Hardening Decisions + +This document captures the behavioral decisions that are now treated as part of +the current executable wedge. It exists to keep major rules out of route code +and to make later reporting, demo, and exception-family work build on explicit +semantics. + +## Scope + +These decisions apply to the current bank-to-VASP reference stack: + +- Travel Rule record lifecycle in the reference server +- instruction terminal and exception semantics +- webhook/event payload contract +- reporting-family boundaries and identifiers + +They do not define long-term normative standards for every future `pacs.crypto` +family member. They define the current executable interpretation for this repo. + +## Travel Rule Callback Lifecycle + +### Core rule + +The Travel Rule callback is a **data-quality acknowledgement**, not an execution +gate and not a sanctions signal. + +The callback status only answers: + +- was the submitted Travel Rule data structurally sufficient for the receiving + VASP's intake and review process +- does the receiving VASP require correction or more review time + +It does not answer: + +- whether the blockchain transfer was or should be broadcast +- whether the receiving VASP cleared or blocked the transfer on sanctions grounds +- whether beneficiary crediting occurred + +### Allowed record lifecycle + +- `POST /travel-rule` creates `SUBMITTED` +- `POST /travel-rule/{recordId}/callback` may move `SUBMITTED` to: + - `ACCEPTED` + - `REJECTED` + - `UNDER_REVIEW` +- `UNDER_REVIEW` is non-terminal and may later move to: + - `ACCEPTED` + - `REJECTED` +- `REJECTED` is correctable via `PUT /travel-rule/{recordId}` and a later follow-up callback +- `ACCEPTED` is terminal for the current wedge + +### Correction semantics + +When correcting a rejected record: + +- the same `record_id` is retained +- the corrected payload is submitted with `PUT /travel-rule/{recordId}` +- `correction_of_callback_ref` should point to the rejected callback being addressed +- a new callback is expected after the corrected data is reviewed + +The reference server keeps the callback chain on the record rather than +forking new child records. This keeps audit linkage simple for the current demo wedge. + +### Conflict rule + +Once a record reaches `ACCEPTED`, a later superseding callback is rejected in the +reference implementation. That makes the accepted state stable for downstream +linking from instructions and reporting. + +## Instruction Terminal Failure Semantics + +### Status taxonomy + +The current wedge distinguishes between: + +- operational completion: `FINAL` +- operator cancellation: `CANCELLED` +- expiry without execution: `EXPIRED` +- generic undisclosed failure: `FAILED` +- bounded execution rejection: `SLIPPAGE_EXCEEDED` +- ramp-specific failure: `RAMP_FAILED` + +### Disclosure rule + +`FAILED` is intentionally generic. + +Where the VASP cannot safely disclose the true operational reason, the system may +return `FAILED` with no detailed `failure_reason`. This preserves the design +principle already stated in the instruction spec: the API must not create a side +channel for sanctions screening outcomes or similar sensitive compliance signals. + +By contrast, `SLIPPAGE_EXCEEDED`, `EXPIRED`, and `RAMP_FAILED` are explicitly +disclosable because they are execution conditions rather than protected compliance findings. + +### Cancellation rule + +Cancellation is allowed only before on-chain broadcast. + +In the current stack that means: + +- cancellable: `PENDING`, `QUOTED` +- no longer cancellable: `BROADCAST`, `CONFIRMING`, `FINAL`, and all terminal failures + +If cancellation is attempted too late, the API returns a conflict rather than +pretending to reverse a chain action that may already be economically final. + +### Slippage rule + +For ramped instructions, the mock EVM adapter may reject the instruction as +`SLIPPAGE_EXCEEDED` before broadcast when the estimated slippage is above the +declared `maximum_slippage_rate`. + +This is treated as a terminal business rejection, not as an in-flight failure. + +## Webhook Event Contract + +### Canonical payload rule + +Webhook payloads must mirror the polling surfaces rather than invent a separate +schema for push delivery. + +The current event families are: + +- `execution_status.updated` +- `finality_receipt.updated` +- `reporting_notification.created` +- `reporting_statement.ready` (internal callback-delivery event) + +For each event: + +- the event `payload` is the same object returned by the matching read endpoint +- the transport envelope adds delivery and event metadata only + +Current reporting exception: + +- generic webhook subscriptions still receive the event envelope +- `POST /report/query` notification subscriptions receive the raw + `BlockchainNotification` body +- `POST /report/query` statement callbacks receive the raw `WalletStatement` + body + +This keeps push and poll aligned and reduces the chance of divergent field semantics. + +### Delivery model + +The current delivery model is outbox-based: + +- instruction and reporting changes write canonical events to the outbox +- matching webhook subscriptions create delivery records +- deliveries are signed and attempted against subscriber endpoints +- retries are persisted with `PENDING`, `RETRYING`, `DELIVERED`, and `FAILED` + +The server now supports both: + +- background dispatch for due deliveries +- manual dispatch forcing via `POST /webhook-deliveries/dispatch` + +Manual dispatch remains useful for demos and deterministic testing. + +### Delivery guarantee rule + +The current guarantee is `AT_LEAST_ONCE_BEST_EFFORT`. + +That means the stack intentionally prefers canonical event persistence plus +retry over any claim of exactly-once delivery. Polling remains the recovery +surface when a receiver needs to reconcile after missed or duplicate push events. + +### Exhaustion rule + +When delivery can no longer make progress, the record moves to terminal +`FAILED` with explicit operator fields: + +- `failure_category` +- `terminal_reason` +- `dead_lettered_at` + +Current terminal reasons are: + +- `MAX_ATTEMPTS_EXHAUSTED` +- `SUBSCRIPTION_INACTIVE` +- `EVENT_MISSING` + +This keeps exhausted delivery behavior visible and auditable instead of leaving +it implicit inside retry counters. + +### Signature rule + +Webhook deliveries are signed with `x-pacscrypto-signature` over: + +- `.` + +The signature metadata is separate from the canonical payload and does not alter +the business object being delivered. + +## Reporting-Family Boundaries And Identifiers + +### Boundary rule + +Reporting remains institution-facing booked-entry reporting. It is not a block +explorer substitute and it is not another status API. + +The current split is: + +- `execution-status`: execution lifecycle and status history +- `finality-receipt`: settlement/finality proof +- `reporting/notifications`: booked-entry notifications +- `reporting/intraday`: movement summaries built from booked notifications +- `reporting/statements`: persisted statement views derived from reporting notifications + +Reporting may reference status and finality, but it must not replace them. + +### Identifier rule + +The same identifiers must flow across families wherever applicable: + +- `instruction_id` +- `uetr` +- `travel_rule_record_id` +- `transaction_hash` + +This keeps traceability stable across: + +- instruction submission +- lifecycle polling +- webhook delivery +- booked reporting outputs + +### Travel Rule linkage rule + +Travel Rule data remains a linked compliance context rather than being duplicated +into every downstream message family. Downstream surfaces reference +`travel_rule_record_id` where relevant instead of embedding the entire Travel Rule payload. + +## What This Prepares Next + +These decisions unblock the next work cleanly: + +- reporting polish can improve statements and traceability without reopening core lifecycle rules +- the demo package can explain exact status/reporting boundaries without hand-waving +- future exception-family design can start from explicit terminal-state semantics diff --git a/docs/visual-flow-map.md b/docs/visual-flow-map.md new file mode 100644 index 0000000..3f24c0d --- /dev/null +++ b/docs/visual-flow-map.md @@ -0,0 +1,96 @@ +# Visual flow map + +This note gives reviewers a visual entry point into the current `pacs.crypto` reference stack. It is intentionally descriptive rather than normative: the implemented reference server is concrete, while the richer investigation family remains draft until the spec design catches up. + +## Scope posture for this draft PR + +- Keep the PR in **draft**. +- Do not expand the normative API surface in this branch. +- Use the visual/reference artifacts to clarify the current lifecycle and boundaries. +- Keep `pacs.004` and `pacs.007` visible as recognised payment vocabulary. +- Treat `camt.056` as a pre-broadcast cancellation lane only. +- Keep future E&I / investigation flows as draft reference-server input, not a spec commitment. + +## Reviewer questions this map answers + +- Where do the Travel Rule record, payment instruction, status reads, finality evidence, returns, reversals, and investigation paths sit in one lifecycle? +- Which parts are already implemented in the reference server? +- Which ISO 20022 analogues are being preserved by name because they are recognised industry vocabulary? +- Which parts are deliberately parked as draft input for future spec work? +- Is the implemented-vs-draft boundary clear enough to support future E&I and liquidity-management discussion without forcing a merge decision? + +## Main lifecycle + +```mermaid +sequenceDiagram + autonumber + participant Bank as Originating bank / treasury + participant SVASP as Sending VASP + participant Chain as Public chain + participant RVASP as Receiving VASP + participant Ops as Exception / investigation ops + + Bank->>SVASP: POST /travel-rule
pacs.008-aligned identity + remittance data + SVASP->>RVASP: Travel Rule callback / acknowledgement + Bank->>SVASP: POST /instruction/quote + Bank->>SVASP: POST /instruction
linked travel_rule_record_id + SVASP->>Chain: Broadcast token transfer + Chain-->>SVASP: PENDING → BROADCAST → CONFIRMING → FINAL + Bank->>SVASP: GET /instruction/{instructionId}
pure status readback + Bank->>SVASP: GET /execution-status/{instructionId}
pacs.002-like execution status + Bank->>SVASP: GET /finality-receipt/{instructionId}
camt.025-like finality evidence + SVASP-->>Bank: camt.054-like notification
camt.052/camt.053-like reporting views + + alt Post-settlement remediation + Bank->>SVASP: POST /instruction/{instructionId}/return
pacs.004 return terminology preserved + Bank->>SVASP: POST /instruction/{instructionId}/reverse
pacs.007-aligned reversal request + Bank->>SVASP: GET /instruction/{instructionId}/reversal-status
poll reversal workflow status + else Pre-broadcast cancellation window + Bank->>SVASP: DELETE /instruction/{instructionId}
camt.056 analogue only before broadcast + else Rich investigation still draft + Bank->>Ops: request-for-information / exception workflow + Ops->>SVASP: investigation_case draft machinery
future camt.026 / camt.027 / camt.087 analogues TBD + end +``` + +## Flow ownership map + +```mermaid +flowchart LR + A[Travel Rule + remittance
pacs.008-aligned] --> B[Instruction quote] + B --> C[Instruction submit] + C --> D[Chain adapter lifecycle
PENDING → BROADCAST → CONFIRMING → FINAL] + D --> E[Status and finality reads] + E --> F[Reporting views
camt.054 / camt.052 / camt.053] + + D --> G[Return request
POST /instruction/{instructionId}/return
pacs.004] + G --> H[Reversal request/status
POST /instruction/{instructionId}/reverse
GET /instruction/{instructionId}/reversal-status
pacs.007] + C -. pre-broadcast only .-> I[Cancellation
camt.056 analogue] + E -. draft / not locked .-> J[Investigation family
camt.026 / camt.027 / camt.087 TBD] + + classDef implemented fill:#e8f5e9,stroke:#1b5e20,color:#111; + classDef partial fill:#fff8e1,stroke:#ff8f00,color:#111; + classDef draft fill:#fce4ec,stroke:#ad1457,color:#111; + class A,B,C,D,E,F,G,H implemented; + class I partial; + class J draft; +``` + +## Alignment notes + +| Area | Current posture | Why | +| --- | --- | --- | +| Draft PR | Keep draft | The branch is a discussion/reference artifact, not a merge request for immediate normative adoption. | +| Normative API scope | Do not expand in this pass | The next change should clarify artifacts and boundaries, not add new endpoints or lock investigation design prematurely. | +| Reverse / return terminology | Preserve `pacs.004` and `pacs.007` wording | These names are recognised industry vocabulary and help reviewers understand the intent quickly. | +| `/instruction/{instructionId}/return` | Use as the public return-review shape | It keeps the v1.2 discussion tied to the instruction lifecycle while the exception-family store remains an implementation detail. | +| `/instruction/{instructionId}/reverse` and `/reversal-status` | Keep `pacs.007` aligned | Post-settlement remediation is the realistic blockchain case once a public-chain transfer is final. | +| `camt.056` | Treat as pre-broadcast cancellation only | On a public chain, the cancellation window after broadcast is essentially zero. | +| Pure status readback | Use `GET /instruction/{instructionId}` | A dedicated richer status-request path is not needed for simple readback in this visual pass. | +| Exception / investigation family | Keep as reference-server draft machinery | Useful concrete input, but the future spec shape should stay open until the design is clearer. | + +## Suggested reviewer use + +1. Open [`../index.html`](../index.html) for the visual console. +2. Use the inline lifecycle map to orient the discussion. +3. Use this note when reviewing the draft exception-family machinery, especially the boundary between implemented reference behaviour and future spec design. diff --git a/docs/visual-showcase.html b/docs/visual-showcase.html new file mode 100644 index 0000000..510a582 --- /dev/null +++ b/docs/visual-showcase.html @@ -0,0 +1,818 @@ + + + + + + +pacs.crypto visual reference — review showcase + + + + + +
+
+ pacs.crypto · reference review +

pacs.crypto visual reference — review showcase

+

+ A discussion artifact for Tom and spec reviewers: a single lifecycle view of the reference + stack, paired with the visual flow video. This page keeps the PR in draft posture and does + not add normative API scope; it is a reading aid for the implemented reference server and + the still-draft investigation family. +

+
+ discussion artifact + draft PR posture + no normative scope expansion + reviewer-facing +
+
+
+ +
+ +
+ 00 · Scope lock +

Draft reference artifact, not normative expansion

+

+ This is the direction Tom's comments support: keep the PR draft, tighten the visual/reference + artifacts, and avoid adding new normative API surface while the E&I design remains open. +

+
+
+ KEEP +

Instruction lifecycle, Travel Rule, finality

+

Show the bank → sending VASP → chain → receiving VASP path and the status/finality evidence that reviewers can inspect.

+ implemented wedge +
+
+ CLARIFY +

pacs.004, pacs.007, camt.056 boundaries

+

Keep recognised return/reversal vocabulary visible, and label camt.056 as pre-broadcast only.

+ alignment note +
+
+ DO NOT LOCK +

Future E&I / investigation lane

+

Keep exception-family machinery as useful reference-server input, without forcing an Investigation API shape in this PR.

+ draft / TBD +
+
+
+ +
+ 01 · Primary cut +

Visual flow video — clean cut

+

+ The non-narrated preview rendered for Tom. Captions are baked in as scene subtitles, so the + cut reads on its own without audio. +

+
+ +
+ Source: videos/visual-flow-map/renders/pacs-crypto-visual-flow-tom-preview.mp4 + Build: scripts/create-visual-flow-video.mjs + Audio: background track only · no narration +
+
+
+ +
+ 01b · Alternate cut +

Narrated cut (optional)

+

+ Same composition with a narration track. Useful if you would rather listen than read the + baked-in captions. +

+
+ Show narrated preview +
+ +

+ Source: videos/visual-flow-map-narrated/renders/pacs-crypto-visual-flow-tom-preview-narrated.mp4 + Lite review copy: videos/visual-flow-map-narrated/renders/pacs-crypto-visual-flow-tom-preview-narrated-lite.mp4 +

+
+
+
+ +
+ 02 · Lifecycle map +

One end-to-end lifecycle, at a glance

+

+ The same path the video walks through: bank instruction enters via Travel Rule, the sending + VASP quotes and broadcasts, the chain settles, the receiving VASP confirms, and reporting and + remediation hang off the end. +

+
+
    +
  1. + Bank + Originating bank / treasury + submits travel-rule + instruction + implemented +
  2. +
  3. + SVASP + Sending VASP + /travel-rule, /instruction + implemented +
  4. +
  5. + Travel Rule + Travel Rule record + pacs.008-aligned identity + remittance + implemented +
  6. +
  7. + Quote + Instruction quote + POST /instruction/quote + implemented +
  8. +
  9. + Instruction + Instruction submit + linked travel_rule_record_id + implemented +
  10. +
  11. + Chain + Chain / finality + PENDING → BROADCAST → CONFIRMING → FINAL + implemented +
  12. +
  13. + RVASP + Receiving VASP + Travel Rule callback / ack + implemented +
  14. +
  15. + Status + Status / reporting / evidence + camt.054 / camt.052 / camt.053 / camt.025-like + implemented +
  16. +
  17. + Return + Instruction return request + POST /instruction/{instructionId}/return · pacs.004 + implemented +
  18. +
  19. + Reverse + Reversal request / status + POST /instruction/{instructionId}/reverse · GET /instruction/{instructionId}/reversal-status · pacs.007 + implemented +
  20. +
  21. + Cancel + Pre-broadcast cancel + camt.056 analogue only + partial +
  22. +
  23. + E&I + Investigation family + camt.026 / camt.027 / camt.087 — TBD + draft / not locked +
  24. +
+
+
+ +
+ 03 · Legend +

Status legend

+

Used on the lifecycle steps above and the alignment cards below.

+
+
implemented
+
concrete in the reference server
+
partial
+
scoped narrower than the ISO 20022 namesake
+
draft / TBD
+
reference-server input, future spec shape kept open
+
+
+ +
+ 04 · ISO 20022 alignment +

Where the ISO 20022 vocabulary lands

+

+ Industry vocabulary is preserved where it helps reviewers recognise intent quickly, and + deliberately narrowed where the public-chain context makes the namesake misleading. +

+
+
+ pacs.004 +

Instruction return request

+

+ POST /instruction/{instructionId}/return keeps pacs.004 + wording visible while the compensating instruction remains explicit. +

+ implemented +
+ +
+ pacs.007 +

Reversal request / status

+

+ POST /instruction/{instructionId}/reverse and + GET /instruction/{instructionId}/reversal-status stay aligned with + post-settlement remediation, not chain undo. +

+ implemented +
+ +
+ camt.056 +

Pre-broadcast cancellation only

+

+ Treated strictly as a pre-broadcast cancellation analogue. After broadcast the + cancellation window is essentially zero on a public chain. +

+ partial +
+ +
+ camt.026 / .027 / .087 +

Investigation family — draft

+

+ Future investigation / exceptions & investigations analogues. Kept as reference-server + draft machinery; the spec shape stays open until the design is clearer. +

+ draft / TBD +
+
+
+ +
+ 05 · Review questions +

For Tom

+

+ Two questions to orient feedback — small enough to answer in a reply, useful enough to steer + the next pass. +

+
    +
  1. + Does this visual reference help orient spec discussion, and is the draft-PR / no-new-normative-scope boundary readable in a single pass? + If yes, we keep the lifecycle / alignment split as the standing review shape. +
  2. +
  3. + Should the next pass focus on the Exceptions & Investigations family, or on liquidity + management around the chain adapter? + Both are listed as draft / open. We will go where you point. +
  4. +
+
+ +
+ 06 · Sources in this repo +

Where the substance lives

+

+ This page summarises; the source notes and console below are the real artifacts. +

+ +
+ +
+ +
+
+ pacs.crypto reference review · discussion artifact, not normative spec content + docs/visual-showcase.html +
+
+ + + diff --git a/docs/webhook-delivery.md b/docs/webhook-delivery.md new file mode 100644 index 0000000..abaae6d --- /dev/null +++ b/docs/webhook-delivery.md @@ -0,0 +1,137 @@ +# pacs.crypto Webhook Delivery Model + +This document captures the current webhook delivery semantics for the reference +stack. + +The goal is operational credibility for the current demo wedge, not a claim of +production-grade guarantees. + +## Delivery Guarantee + +The current delivery guarantee is: + +- `AT_LEAST_ONCE_BEST_EFFORT` + +That means: + +- matching subscriptions should receive each outbox event at least once when the + endpoint is reachable +- duplicate delivery is possible +- exactly-once delivery is not guaranteed +- global ordering across subscriptions is not guaranteed + +Polling remains the canonical recovery path when a receiver needs to reconcile +state. + +## Canonical Push Model + +The push model remains aligned to the polling model: + +- outbox events carry canonical business objects +- webhook envelopes add transport metadata only +- the `payload` equals the object returned by the matching read endpoint + +Reporting note: + +- generic `/webhook-endpoints` subscriptions receive the event envelope +- `POST /report/query` notification subscriptions receive the raw + `BlockchainNotification` message body from the root reporting spec +- `POST /report/query` statement callbacks receive the raw `WalletStatement` + message body from the root reporting spec +- receivers can distinguish the mode via `x-pacscrypto-payload-mode` + +Current event families: + +- `execution_status.updated` +- `finality_receipt.updated` +- `reporting_notification.created` +- `reporting_statement.ready` (internal callback-delivery event) + +## Delivery States + +Current persisted delivery states: + +- `PENDING` +- `RETRYING` +- `DELIVERED` +- `FAILED` + +Additional operator fields now make terminal handling explicit: + +- `failure_category` +- `terminal_reason` +- `dead_lettered_at` +- `delivery_guarantee` + +Current terminal reasons: + +- `MAX_ATTEMPTS_EXHAUSTED` +- `SUBSCRIPTION_INACTIVE` +- `EVENT_MISSING` + +## Retry Model + +The server supports: + +- background dispatch for due deliveries +- manual dispatch forcing via `POST /webhook-deliveries/dispatch` +- persisted retry scheduling using `next_attempt_at` +- per-subscription `max_attempts` + +Retry delays are controlled by: + +- `REF_SERVER_WEBHOOK_RETRY_SCHEDULE_MS` + +Background worker behavior is controlled by: + +- `REF_SERVER_WEBHOOK_AUTO_DISPATCH` +- `REF_SERVER_WEBHOOK_DISPATCH_INTERVAL_MS` +- `REF_SERVER_WEBHOOK_DISPATCH_BATCH_SIZE` + +## Dead-Letter Handling + +A delivery is considered dead-lettered when it reaches `FAILED` with +`dead_lettered_at` populated. + +Current operator reads: + +- `GET /webhook-deliveries` +- `GET /webhook-deliveries/stats` +- `GET /webhook-deliveries/dead-letter` +- `GET /webhook-deliveries/:deliveryId` + +These surfaces let an operator or demo user inspect: + +- queued work +- retrying work +- exhausted deliveries +- current retry schedule and delivery counts + +## Signature Rule + +Webhook deliveries are signed over: + +- `.` + +Headers: + +- `x-pacscrypto-signature` +- `x-pacscrypto-signature-timestamp` +- `x-pacscrypto-event-id` +- `x-pacscrypto-delivery-id` +- `x-pacscrypto-event-type` +- `x-pacscrypto-payload-mode` + +## Scope Limit + +This is still a demo-grade notification subsystem. + +It does not yet claim: + +- durable worker sharding +- ordered fanout guarantees +- endpoint health suppression windows +- replay tooling +- exactly-once consumer semantics + +Those belong after the current reviewer/demo package, not before. diff --git a/index.html b/index.html new file mode 100644 index 0000000..2d30c23 --- /dev/null +++ b/index.html @@ -0,0 +1,773 @@ + + + + + + +pacs.crypto Demo Console + + + + + +
+
+

pacs.crypto

+

pacs.crypto Demo Console

+

+ A reviewer-facing index for the local pacs.crypto wedge: bank → sending VASP → + EVM USDC transfer → receiving VASP → finality and reporting evidence. Everything below + links to local simulators, OpenAPI specs, or design docs in this repository. +

+
+
+ +
+ +
+

End-to-end lifecycle

+

+ The current wedge is intentionally narrow: one asset (USDC), one chain + family (EVM), one custody model (FULL_CUSTODY), one corridor + (bank → sending VASP → on-chain transfer → receiving VASP). +

+
    +
  1. PENDING
  2. +
  3. BROADCAST
  4. +
  5. CONFIRMING
  6. +
  7. FINAL
  8. +
+ +
+ + + + + + + + + + + +
+
+ +
+

Visual flow map

+

+ A reviewer-facing map for Tom's design questions: what is implemented now, where + pacs.004, pacs.007, and camt.056 fit, and where + richer investigation flows stay draft until the spec design matures. This keeps the PR + draft and does not add normative API scope. +

+ + + +
+ Implemented reference-server or demo surface + Limited to a narrow lifecycle window + Useful draft machinery, not locked into the spec +
+ + +
+ +
+

Spec layers

+

+ Three OpenAPI documents form the current specification surface. Each ships with a + standalone HTML simulator that runs in Demo mode out of the box and in + Live API mode against the local reference server. +

+
+ + + +
+
+ +
+

Local reference server

+

+ A live status probe of http://127.0.0.1:5050/health. + Nothing on this page depends on the server being up — the simulators above also work + in their bundled Demo mode. +

+ +
+
+ + Idle + + How to start it +
+

+ Click Check server to probe the local reference server. +

+ +
+ +
+ Start command +

+ From the repository root: +

+
cd reference-server
+npm ci
+npm start
+

+ Defaults to http://127.0.0.1:5050. npm ci uses the tracked + lockfile for a clean local install; see + reference-server/README.md for the full set of + environment overrides, including the optional Sepolia + USDC broadcast path. +

+
+
+ +
+

Reviewer documentation

+

+ Background reading for what is in the wedge today and what is not. +

+ +
+
+ Direction and scope + +
+ +
+ Conformance and semantics + +
+ +
+ Sample evidence + +
+ +
+ Reference server + +
+
+
+
+ +
+
+

+ pacs.crypto is offered as a community proposal. This page is a static index and links + only to local files in this repository. +

+
+
+ + + + diff --git a/instruction-simulator-v1.html b/instruction-simulator-v1.html index 6078d5d..8458f1c 100644 --- a/instruction-simulator-v1.html +++ b/instruction-simulator-v1.html @@ -127,6 +127,7 @@ .s201,.s200{background:var(--green-bg);border:1px solid rgba(63,185,80,.35);color:var(--green)} .s409{background:rgba(210,153,34,.1);border:1px solid rgba(210,153,34,.3);color:var(--amber)} .s404,.s400,.s422{background:rgba(248,81,73,.1);border:1px solid rgba(248,81,73,.3);color:var(--red)} + .s500{background:rgba(210,153,34,.1);border:1px solid rgba(210,153,34,.3);color:var(--amber)} .rt{font-family:var(--mono);font-size:10px;color:var(--dim);margin-left:auto} .rb{flex:1;overflow-y:auto;padding:16px;display:flex;flex-direction:column;gap:10px} @@ -420,6 +421,15 @@

Blockchain Payment Instruction API v1

+
Execution Mode
+
Mode
+
+
API Base URL used in LIVE mode
+
+ @@ -459,7 +469,7 @@

Blockchain Payment Instruction API v1

Name MANDATORY
LEI
-
+ @@ -664,7 +674,7 @@

Blockchain Payment Instruction API v1

daname:'Bitvavo B.V.', dalei:'7245007VX57GR4IUVZ79', dabic:'BITVNL2AXXX', cname:'Bravo Supplies B.V.', clei:'724500QHKL6MVSQQ1Z17', cctry:'NL', cwallet:'0xde0B295669a9FD93d5F28D9Ec85E40f4cb697BAe', - caname:'Kraken Belgium BVBA', calei:'5299000DUFB71VFOHVB49' }, + caname:'Kraken Belgium BVBA', calei:'635400DUFB71VFOHVB49' }, chain: { chain:'DLID/X9J9XDMTD', tokendti:'T9B3X8H2K', tokensym:'USDC', contract:'0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', tokstd:'ERC-20', issuer:'Circle Internet Financial LLC', issuerlei:'549300EZRIYGDZHEDL36' }, @@ -683,7 +693,7 @@

Blockchain Payment Instruction API v1

daname:'Bitvavo B.V.', dalei:'7245007VX57GR4IUVZ79', dabic:'BITVNL2AXXX', cname:'Bravo Supplies B.V.', clei:'724500QHKL6MVSQQ1Z17', cctry:'NL', cwallet:'0xde0B295669a9FD93d5F28D9Ec85E40f4cb697BAe', - caname:'Kraken Belgium BVBA', calei:'5299000DUFB71VFOHVB49' }, + caname:'Kraken Belgium BVBA', calei:'635400DUFB71VFOHVB49' }, chain: { chain:'DLID/X9J9XDMTD', tokendti:'T9B3X8H2K', tokensym:'USDC', contract:'0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', tokstd:'ERC-20', issuer:'Circle Internet Financial LLC', issuerlei:'549300EZRIYGDZHEDL36' }, @@ -720,7 +730,7 @@

Blockchain Payment Instruction API v1

daname:'Bitvavo B.V.', dalei:'7245007VX57GR4IUVZ79', dabic:'BITVNL2AXXX', cname:'Bravo Supplies B.V.', clei:'724500QHKL6MVSQQ1Z17', cctry:'NL', cwallet:'0xde0B295669a9FD93d5F28D9Ec85E40f4cb697BAe', - caname:'Kraken Belgium BVBA', calei:'5299000DUFB71VFOHVB49' }, + caname:'Kraken Belgium BVBA', calei:'635400DUFB71VFOHVB49' }, chain: { chain:'DLID/X9J9XDMTD', tokendti:'T9B3X8H2K', tokensym:'USDC', contract:'0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', tokstd:'ERC-20', issuer:'Circle Internet Financial LLC', issuerlei:'549300EZRIYGDZHEDL36' }, @@ -978,11 +988,706 @@

Blockchain Payment Instruction API v1

} // ── SEND REQUEST ───────────────────────────────────────────────────────────── -function sendRequest() { +function getExecutionMode() { + return g('f-mode') || 'DEMO'; +} + +function getApiBase() { + return (g('f-api-base') || 'http://127.0.0.1:5050').replace(/\/+$/, ''); +} + +function escapeHtml(value) { + return String(value) + .replace(/&/g, '&') + .replace(//g, '>'); +} + +function tryParseJson(text) { + if (!text) return null; + try { + return JSON.parse(text); + } catch { + return null; + } +} + +function getStatusPillClass(status) { + if (status === 'FINAL') return 'st-final'; + if (status === 'CONFIRMING') return 'st-confirming'; + if (['PENDING', 'BROADCAST', 'QUOTED'].includes(status)) return 'st-pending'; + if (['FAILED', 'SLIPPAGE_EXCEEDED', 'RAMP_FAILED'].includes(status)) return 'st-failed'; + if (['CANCELLED', 'EXPIRED'].includes(status)) return 'st-cancelled'; + return 'st-pending'; +} + +async function requestJson(url, init) { + const response = await fetch(url, init); + const rawText = await response.text(); + const payload = tryParseJson(rawText); + return { + ok: response.ok, + status: response.status, + url, + payload, + rawText + }; +} + +async function resolveInstructionReference(referenceValue) { + const base = getApiBase(); + const encoded = encodeURIComponent(referenceValue); + const byUetr = await requestJson(`${base}/execution-status/uetr/${encoded}`); + if (byUetr.ok && byUetr.payload?.instruction_id) { + return { + ok: true, + referenceType: 'uetr', + instructionId: byUetr.payload.instruction_id, + executionStatus: byUetr + }; + } + + const byInstructionId = await requestJson(`${base}/execution-status/${encoded}`); + if (byInstructionId.ok && byInstructionId.payload?.instruction_id) { + return { + ok: true, + referenceType: 'instruction_id', + instructionId: byInstructionId.payload.instruction_id, + executionStatus: byInstructionId + }; + } + + return { + ok: false, + attempts: [byUetr, byInstructionId] + }; +} + +function renderJsonPayloadCard(icon, title, payload, rawText) { + const pretty = payload !== undefined && payload !== null + ? JSON.stringify(payload, null, 2) + : (rawText || 'null'); + return ` +
+
${icon}${title}
+
+
${highlight(pretty)}
+
+
`; +} + +function renderStatusHistory(history) { + if (!Array.isArray(history) || history.length === 0) { + return `
No status history recorded.
`; + } + + return ` +
+ ${history.map(event => ` +
+
+ #${event.sequence} · ${escapeHtml(event.status_at || '—')} + ${escapeHtml(event.status)} +
+
+
reason_code ${escapeHtml(event.reason_code || '—')}
+
${escapeHtml(event.description || '—')}
+
+
`).join('')} +
`; +} + +function truncateMid(value, front = 10, back = 8) { + const text = String(value ?? '—'); + if (text.length <= front + back + 3) { + return text; + } + return `${text.slice(0, front)}…${text.slice(-back)}`; +} + +function renderReportingNotificationSummary(notification) { + const amount = notification?.amount ?? '—'; + const currency = notification?.currency ?? ''; + const party = notification?.party_name || '—'; + const counterparty = notification?.counterparty_name || '—'; + return ` +
+
+ ${escapeHtml(truncateMid(notification?.notification_id, 12, 8))} + + ${escapeHtml(notification?.entry_type || '—')} · ${escapeHtml(notification?.account_role || '—')} + +
+
+ ${escapeHtml(party)} + + ${escapeHtml(counterparty)} +
+
+ ${escapeHtml(amount)} ${escapeHtml(currency)} + ${escapeHtml(notification?.token_symbol || '—')} + ${escapeHtml(notification?.booking_date_time || '—')} +
+
+ ${escapeHtml(notification?.chain_dli || '—')} · ${escapeHtml(notification?.transaction_hash || '—')} + ${notification?.travel_rule_record_id ? ` · TR ${escapeHtml(truncateMid(notification.travel_rule_record_id, 8, 6))}` : ''} +
+
`; +} + +function renderTraceabilityCard(traceability, title = 'Traceability') { + if (!traceability) { + return ''; + } + + const resourcePaths = Object.entries(traceability.resource_paths || {}).filter(([, value]) => Boolean(value)); + return ` +
+
🧷${escapeHtml(title)}
+
+
instruction_id
${escapeHtml(traceability.instruction_id || '—')}
+
uetr
${escapeHtml(traceability.uetr || '—')}
+
end_to_end_identification
${escapeHtml(traceability.end_to_end_identification || '—')}
+
travel_rule_record_id
${escapeHtml(traceability.travel_rule_record_id || '—')}
+
transaction_hash
${escapeHtml(traceability.transaction_hash || '—')}
+ ${Array.isArray(traceability.source_notification_ids) && traceability.source_notification_ids.length + ? `
source_notification_ids
${traceability.source_notification_ids.map((id) => escapeHtml(truncateMid(id, 10, 6))).join(', ')}
` + : ''} + ${resourcePaths.length + ? resourcePaths.map(([key, value]) => ` +
${escapeHtml(key)}
${escapeHtml(value)}
+ `).join('') + : ''} +
+
`; +} + +function renderReportingNotificationDetail(notification) { + if (!notification) { + return `
No reporting notification detail available.
`; + } + + return ` +
+
🧾Reporting Notification Detail
+
+
notification_id
${escapeHtml(notification.notification_id)}
+
message_family
${escapeHtml(notification.message_family || '—')}
+
entry_type
${escapeHtml(notification.entry_type || '—')}
+
account_role
${escapeHtml(notification.account_role || '—')}
+
booking_status
${escapeHtml(notification.booking_status || '—')}
+
booking_date_time
${escapeHtml(notification.booking_date_time || '—')}
+
value_date_time
${escapeHtml(notification.value_date_time || '—')}
+
instruction_id
${escapeHtml(notification.instruction_id || '—')}
+
uetr
${escapeHtml(notification.uetr || '—')}
+
travel_rule_record_id
${escapeHtml(notification.travel_rule_record_id || '—')}
+
status_reference.trigger_status
${escapeHtml(notification.status_reference?.trigger_status || '—')}
+
status_reference.current_instruction_status
${escapeHtml(notification.status_reference?.current_instruction_status || '—')}
+
party
${escapeHtml(notification.party?.name || '—')} · ${escapeHtml(notification.party?.wallet_address || '—')}
+
counterparty
${escapeHtml(notification.counterparty?.name || '—')} · ${escapeHtml(notification.counterparty?.wallet_address || '—')}
+
settlement_amount
+
chain_dli
${escapeHtml(notification.chain_dli || '—')}
+
token
${escapeHtml(notification.token?.token_symbol || '—')} ${notification.token?.token_dti ? `· ${escapeHtml(notification.token.token_dti)}` : ''}
+
transaction_hash
${escapeHtml(notification.transaction_hash || '—')}
+ ${notification.remittance_information?.structured?.creditor_reference_information?.reference + ? `
remittance_reference
${escapeHtml(notification.remittance_information.structured.creditor_reference_information.reference)}
` + : ''} + ${notification.remittance_information?.unstructured + ? `
unstructured_remittance
${escapeHtml(notification.remittance_information.unstructured)}
` + : ''} +
+
+ ${renderTraceabilityCard(notification.traceability, 'Notification Traceability')}`; +} + +function renderIntradayReportingView(intradayPayload) { + if (!intradayPayload) { + return `
No intraday reporting view available.
`; + } + + const totals = Array.isArray(intradayPayload.movement_summary?.totals) + ? intradayPayload.movement_summary.totals + : []; + const accountViews = Array.isArray(intradayPayload.account_views) + ? intradayPayload.account_views + : []; + + return ` +
+
📈Intraday Reporting View
+
+
notification_count
${escapeHtml(intradayPayload.movement_summary?.notification_count ?? '—')}
+
debit_count
${escapeHtml(intradayPayload.movement_summary?.debit_count ?? '—')}
+
credit_count
${escapeHtml(intradayPayload.movement_summary?.credit_count ?? '—')}
+
generated_at
${escapeHtml(intradayPayload.generated_at || '—')}
+
period
${escapeHtml(intradayPayload.period?.from || '—')} → ${escapeHtml(intradayPayload.period?.to || '—')}
+
filters_applied.instruction_id
${escapeHtml(intradayPayload.filters_applied?.instruction_id || '—')}
+
filters_applied.uetr
${escapeHtml(intradayPayload.filters_applied?.uetr || '—')}
+
+
+ ${renderTraceabilityCard(intradayPayload.traceability, 'Intraday Traceability')} +
+
💱Movement Totals
+
+ ${totals.length ? totals.map((total) => ` +
+
+ ${escapeHtml(total.currency || '—')}${total.token_symbol ? ` · ${escapeHtml(total.token_symbol)}` : ''} + ${escapeHtml(total.notification_count ?? '0')} entries +
+
+ Debit ${escapeHtml(total.debit_total || '0')} + Credit ${escapeHtml(total.credit_total || '0')} + Net ${escapeHtml(total.net_total || '0')} +
+
+ `).join('') : `
No booked movement totals.
`} +
+
+
+
🏦Intraday Account Views
+
+ ${accountViews.length ? accountViews.map((account) => ` +
+
+ ${escapeHtml(account.account_role || '—')} · ${escapeHtml(account.party_name || '—')} + ${escapeHtml(account.notification_count ?? '0')} notifications +
+
+ ${escapeHtml(account.chain_dli || '—')} + ${escapeHtml(account.wallet_address || '—')} +
+
+ ${Array.isArray(account.movement_totals) ? account.movement_totals.map((movement) => + `${escapeHtml(movement.currency || '—')}${movement.token_symbol ? `/${escapeHtml(movement.token_symbol)}` : ''}: ${escapeHtml(movement.net_total || '0')}` + ).join(' · ') : '—'} +
+
+ ${Array.isArray(account.instruction_ids) && account.instruction_ids.length ? `Instructions ${account.instruction_ids.map((id) => escapeHtml(truncateMid(id, 10, 6))).join(', ')}` : ''} + ${Array.isArray(account.travel_rule_record_ids) && account.travel_rule_record_ids.length ? ` · Travel Rule ${account.travel_rule_record_ids.map((id) => escapeHtml(truncateMid(id, 10, 6))).join(', ')}` : ''} +
+
+ `).join('') : `
No intraday account views.
`} +
+
`; +} + +function renderReportingStatementSummary(statement) { + return ` +
+
+ ${escapeHtml(truncateMid(statement?.statement_id, 12, 8))} + ${escapeHtml(statement?.account_role || '—')} · ${escapeHtml(statement?.statement_type || '—')} +
+
+ ${escapeHtml(statement?.party_name || '—')} + + ${escapeHtml(statement?.counterparty_name || '—')} +
+
+ Close ${escapeHtml(statement?.balance_summary?.closing_balance?.amount || '—')} ${escapeHtml(statement?.balance_summary?.closing_balance?.currency || '')} + ${escapeHtml(statement?.token_symbol || '—')} + ${escapeHtml(statement?.statement_date || '—')} +
+
+ ${escapeHtml(statement?.chain_dli || '—')} · ${escapeHtml(statement?.transaction_hash || '—')} + ${statement?.travel_rule_record_id ? ` · TR ${escapeHtml(truncateMid(statement.travel_rule_record_id, 8, 6))}` : ''} +
+
`; +} + +function renderReportingStatementDetail(statement) { + if (!statement) { + return `
No reporting statement detail available.
`; + } + + const entries = Array.isArray(statement.entries) ? statement.entries : []; + return ` +
+
📒Reporting Statement Detail
+
+
statement_id
${escapeHtml(statement.statement_id || '—')}
+
statement_type
${escapeHtml(statement.statement_type || '—')}
+
statement_date
${escapeHtml(statement.statement_date || '—')}
+
instruction_id
${escapeHtml(statement.instruction_id || '—')}
+
uetr
${escapeHtml(statement.uetr || '—')}
+
travel_rule_record_id
${escapeHtml(statement.travel_rule_record_id || '—')}
+
period
${escapeHtml(statement.period?.from || '—')} → ${escapeHtml(statement.period?.to || '—')}
+
statement_scope.derivation_basis
${escapeHtml(statement.statement_scope?.derivation_basis || '—')}
+
statement_scope.source_notification_count
${escapeHtml(statement.statement_scope?.source_notification_count ?? '—')}
+
closing_balance
+
net_total
${escapeHtml(statement.movement_summary?.net_total || '—')}
+
finality_status
${escapeHtml(statement.instruction_context?.finality_status || '—')}
+
transaction_hash
${escapeHtml(statement.transaction_hash || '—')}
+
+
+
+
🧾Statement Entries
+
+ ${entries.length ? entries.map((entry) => renderReportingNotificationSummary(entry)).join('') : `
No statement entries.
`} +
+
+ ${renderTraceabilityCard(statement.traceability, 'Statement Traceability')}`; +} + +function renderLiveReadResponse(referenceValue, resolved, instructionPayload, executionStatusPayload, finalityPayload, reportingBundle, elapsed) { + const status = executionStatusPayload?.status || instructionPayload?.status || 'UNKNOWN'; + const finalityStatus = finalityPayload?.finality_status || executionStatusPayload?.finality_status || 'PENDING'; + const confirmationDepth = Number( + finalityPayload?.confirmation_depth ?? executionStatusPayload?.confirmation_depth ?? 0 + ); + const requiredDepth = Number( + finalityPayload?.required_confirmation_depth ?? executionStatusPayload?.required_confirmation_depth ?? 0 + ); + const progressPct = requiredDepth > 0 + ? Math.max(0, Math.min(100, Math.round((confirmationDepth / requiredDepth) * 100))) + : 0; + const progressClass = finalityStatus === 'FINAL' ? 'fin-final' : 'fin-prob'; + const notApplicableReason = finalityPayload?.not_applicable_reason; + const hasReportingView = finalityStatus === 'FINAL' || status === 'FINAL'; + const notificationsResponse = reportingBundle?.notificationsResponse; + const intradayResponse = reportingBundle?.intradayResponse; + const notificationDetailResponse = reportingBundle?.notificationDetailResponse; + const statementsResponse = reportingBundle?.statementsResponse; + const statementDetailResponse = reportingBundle?.statementDetailResponse; + const notifications = Array.isArray(notificationsResponse?.payload?.notifications) + ? notificationsResponse.payload.notifications + : []; + const statements = Array.isArray(statementsResponse?.payload?.statements) + ? statementsResponse.payload.statements + : []; + const hasReportingContent = Boolean( + notificationsResponse?.ok || + intradayResponse?.ok || + notificationDetailResponse?.ok || + statementsResponse?.ok || + statementDetailResponse?.ok + ); + const reportingTraceability = + notificationDetailResponse?.payload?.traceability || + statementDetailResponse?.payload?.traceability || + intradayResponse?.payload?.traceability || + null; + + document.getElementById('rh').innerHTML = + `200 + GET /execution-status + /finality-receipt + /reporting/* + /instruction + ${elapsed}ms`; + + document.getElementById('rbody').innerHTML = ` +
+
+
+ 🧭Live Status Read Model +
+
+
lookup_value
${escapeHtml(referenceValue)}
+
resolved_as
${escapeHtml(resolved.referenceType)}
+
instruction_id
${escapeHtml(resolved.instructionId)}
+
uetr
${escapeHtml(executionStatusPayload?.uetr || instructionPayload?.uetr || '—')}
+
execution_mode
LIVE API
+
+
+ + ${reportingTraceability ? renderTraceabilityCard(reportingTraceability, 'End-to-End Traceability') : ''} + +
+
+ ${status === 'FINAL' ? '✅' : status === 'CONFIRMING' ? '⏳' : '📍'} + Execution Status Surface +
+
+
status
${escapeHtml(status)}
+
status_group
${escapeHtml(executionStatusPayload?.status_group || '—')}
+
latest_status_at
${escapeHtml(executionStatusPayload?.latest_status_at || '—')}
+
transaction_hash
${escapeHtml(executionStatusPayload?.transaction_hash || '—')}
+
debit_timing
${escapeHtml(executionStatusPayload?.debit_timing || instructionPayload?.debit_timing || '—')}
+
failure_reason
${escapeHtml(executionStatusPayload?.failure_reason || instructionPayload?.failure_reason || '—')}
+
+
+ +
+
+ Finality Receipt Surface +
+
+
instruction_status
${escapeHtml(finalityPayload?.instruction_status || status)}
+
finality_status
${escapeHtml(finalityStatus)}
+
confirmation_depth
${requiredDepth > 0 ? `${confirmationDepth} / ${requiredDepth}` : escapeHtml(String(confirmationDepth))}
+
block_number
${escapeHtml(finalityPayload?.block_number ?? '—')}
+
block_timestamp
${escapeHtml(finalityPayload?.block_timestamp || '—')}
+
final_at
${escapeHtml(finalityPayload?.final_at || '—')}
+ ${notApplicableReason ? `
not_applicable_reason
` : ''} +
+
+ Confirmation progress${requiredDepth > 0 ? `${confirmationDepth} / ${requiredDepth}` : 'n/a'} +
+
+
+
+
+
+
+ +
+
🕓Status History
+
+ ${renderStatusHistory(executionStatusPayload?.status_history)} +
+
+ + ${hasReportingView && hasReportingContent + ? ` +
+
📬Reporting Notifications
+
+ ${notificationsResponse?.ok ? ` +
total_matched
${escapeHtml(notificationsResponse.payload?.total_matched ?? '0')}
+
page_size
${escapeHtml(notificationsResponse.payload?.page_size ?? '0')}
+
generated_at
${escapeHtml(notificationsResponse.payload?.generated_at || '—')}
+
next_cursor
${escapeHtml(notificationsResponse.payload?.next_cursor ?? 'null')}
+ ` : ` +
notifications_status
Reporting notifications unavailable
+ `} +
+ ${notifications.length ? notifications.map(renderReportingNotificationSummary).join('') : `
No reporting notifications returned.
`} +
+
+
+ ${notificationDetailResponse?.ok ? renderReportingNotificationDetail(notificationDetailResponse.payload) : ''} + ${intradayResponse?.ok ? renderIntradayReportingView(intradayResponse.payload) : ''} +
+
📚Reporting Statements
+
+ ${statementsResponse?.ok ? ` +
total_matched
${escapeHtml(statementsResponse.payload?.total_matched ?? '0')}
+
page_size
${escapeHtml(statementsResponse.payload?.page_size ?? '0')}
+
generated_at
${escapeHtml(statementsResponse.payload?.generated_at || '—')}
+ ` : ` +
statements_status
Reporting statements unavailable
+ `} +
+ ${statements.length ? statements.map(renderReportingStatementSummary).join('') : `
No reporting statements returned.
`} +
+
+
+ ${statementDetailResponse?.ok ? renderReportingStatementDetail(statementDetailResponse.payload) : ''} + ` + : ` + ${renderJsonPayloadCard('📦', 'Instruction Payload', instructionPayload)} + ${renderJsonPayloadCard('📦', 'Execution Status Payload', executionStatusPayload)} + ${renderJsonPayloadCard('📦', 'Finality Receipt Payload', finalityPayload)} + `} +
`; +} + +function renderLiveLookupFailure(referenceValue, resolved, elapsed) { + const attempts = resolved.attempts || []; + const primaryAttempt = attempts.find(a => a.status) || { status: 404, payload: { message: 'Instruction execution status not found.' } }; + const statusClass = primaryAttempt.status >= 400 && primaryAttempt.status < 500 ? 's400' : 's500'; + document.getElementById('rh').innerHTML = + `${primaryAttempt.status} + GET /execution-status lookup + ${elapsed}ms`; + document.getElementById('rbody').innerHTML = ` +
+
+
+ Instruction Lookup Failed +
+
+
lookup_value
${escapeHtml(referenceValue)}
+
message
${escapeHtml(primaryAttempt.payload?.message || 'Instruction execution status not found.')}
+
+
+ ${attempts.map((attempt, index) => renderJsonPayloadCard( + '📦', + index === 0 ? 'Lookup Attempt — /execution-status/uetr' : 'Lookup Attempt — /execution-status/{instructionId}', + attempt.payload, + attempt.rawText + )).join('')} +
`; +} + +function buildLiveRequest(req) { + const base = getApiBase(); + if (req._type === 'QUOTE') { + const { _type, ...body } = req; + return { method:'POST', url:`${base}/instruction/quote`, body }; + } + if (req._type === 'POST') { + const { _type, ...body } = req; + return { method:'POST', url:`${base}/instruction`, body }; + } + if (req._type === 'GET') { + return { method:'GET', url:`${base}/instruction/${encodeURIComponent(req.instruction_id)}` }; + } + if (req._type === 'DELETE') { + return { method:'DELETE', url:`${base}/instruction/${encodeURIComponent(req.instruction_id)}` }; + } + if (req._type === 'SEARCH') { + const { _type, ...params } = req; + const qs = new URLSearchParams(); + Object.entries(params).forEach(([k,v]) => { + if (v !== undefined && v !== '') qs.set(k, String(v)); + }); + return { method:'GET', url:`${base}/instruction/search${qs.toString() ? '?' + qs.toString() : ''}` }; + } + return { method:'POST', url:`${base}/instruction`, body:req }; +} + +function renderLiveResponse(method, url, status, elapsed, payload, rawText) { + const statusClass = status >= 200 && status < 300 ? 's200' : status >= 400 && status < 500 ? 's400' : 's500'; + const pretty = payload !== undefined ? JSON.stringify(payload, null, 2) : rawText || ''; + document.getElementById('rh').innerHTML = + `${status} + ${method} ${url.replace(getApiBase(),'')} + ${elapsed}ms`; + document.getElementById('rbody').innerHTML = ` +
+
+
+ 🌐Live API Response +
+
+
execution_mode
LIVE API
+
api_base
${getApiBase()}
+
http_status
${status}
+
+
+
+
📦Response Payload
+
+
${highlight(pretty || 'null')}
+
+
+
`; +} + +async function sendLiveRequest(req) { + const started = performance.now(); + + try { + if (req._type === 'GET') { + const resolved = await resolveInstructionReference(req.instruction_id); + const elapsed = Math.round(performance.now() - started); + if (!resolved.ok) { + renderLiveLookupFailure(req.instruction_id, resolved, elapsed); + return; + } + + const base = getApiBase(); + const [instructionResponse, finalityResponse] = await Promise.all([ + requestJson(`${base}/instruction/${encodeURIComponent(resolved.instructionId)}`), + requestJson(`${base}/finality-receipt/${encodeURIComponent(resolved.instructionId)}`) + ]); + const instructionStatus = instructionResponse.payload?.status || resolved.executionStatus?.payload?.status || 'UNKNOWN'; + const finalityStatus = finalityResponse.payload?.finality_status || 'PENDING'; + const hasReportingView = instructionStatus === 'FINAL' || finalityStatus === 'FINAL'; + let reportingBundle = null; + + if (hasReportingView) { + const instructionIdParam = encodeURIComponent(resolved.instructionId); + const [notificationsResponse, intradayResponse, statementsResponse] = await Promise.all([ + requestJson(`${base}/reporting/notifications?instruction_id=${instructionIdParam}`), + requestJson(`${base}/reporting/intraday?instruction_id=${instructionIdParam}`), + requestJson(`${base}/reporting/statements?instruction_id=${instructionIdParam}`) + ]); + const firstNotificationId = notificationsResponse.payload?.notifications?.[0]?.notification_id; + const firstStatementId = statementsResponse.payload?.statements?.[0]?.statement_id; + const notificationDetailResponse = firstNotificationId + ? await requestJson(`${base}/reporting/notifications/${encodeURIComponent(firstNotificationId)}`) + : null; + const statementDetailResponse = firstStatementId + ? await requestJson(`${base}/reporting/statements/${encodeURIComponent(firstStatementId)}`) + : null; + + reportingBundle = { + notificationsResponse, + intradayResponse, + notificationDetailResponse, + statementsResponse, + statementDetailResponse, + }; + } + + simState.instructionId = resolved.instructionId; + if (resolved.executionStatus?.payload?.uetr) { + s('f-uetr', resolved.executionStatus.payload.uetr); + } + + renderLiveReadResponse( + req.instruction_id, + resolved, + instructionResponse.payload, + resolved.executionStatus.payload, + finalityResponse.payload, + reportingBundle, + elapsed + ); + return; + } + + if (req._type === 'DELETE') { + const resolved = await resolveInstructionReference(req.instruction_id); + if (!resolved.ok) { + const elapsed = Math.round(performance.now() - started); + renderLiveLookupFailure(req.instruction_id, resolved, elapsed); + return; + } + + const live = { + method: 'DELETE', + url: `${getApiBase()}/instruction/${encodeURIComponent(resolved.instructionId)}` + }; + const response = await requestJson(live.url, { method: live.method }); + const elapsed = Math.round(performance.now() - started); + + if (response.payload?.instruction_id) { + simState.instructionId = response.payload.instruction_id; + s('f-uetr', response.payload.uetr || resolved.executionStatus?.payload?.uetr || response.payload.instruction_id); + } + + renderLiveResponse(live.method, live.url, response.status, elapsed, response.payload, response.rawText); + return; + } + + const live = buildLiveRequest(req); + const response = await requestJson(live.url, { + method: live.method, + headers: live.body ? { 'Content-Type':'application/json' } : undefined, + body: live.body ? JSON.stringify(live.body) : undefined + }); + const elapsed = Math.round(performance.now() - started); + + if (response.payload?.quote_id) { + simState.quoteId = response.payload.quote_id; + s('f-quoteid', response.payload.quote_id); + } + if (response.payload?.instruction_id) { + simState.instructionId = response.payload.instruction_id; + s('f-uetr', response.payload.uetr || response.payload.instruction_id); + } + + renderLiveResponse(live.method, live.url, response.status, elapsed, response.payload, response.rawText); + } catch (error) { + const elapsed = Math.round(performance.now() - started); + renderLiveResponse('REQUEST', getApiBase(), 0, elapsed, { + error: 'connection_failed', + message: error.message + }, ''); + } +} + +async function sendRequest() { updatePreview(); const req = buildReq(); document.getElementById('rh').innerHTML = `
`; document.getElementById('rbody').innerHTML = `
Processing…
`; + if (getExecutionMode() === 'LIVE') { + await sendLiveRequest(req); + return; + } setTimeout(() => renderResponse(req), 620 + Math.random() * 400); } diff --git a/reference-server/README.md b/reference-server/README.md new file mode 100644 index 0000000..e2206c5 --- /dev/null +++ b/reference-server/README.md @@ -0,0 +1,209 @@ +# pacs.crypto Reference Server + +First executable slice of the `pacs.crypto` reference stack. + +Conformance status for the spec-covered routes is tracked in [`../docs/conformance.md`](../docs/conformance.md). +Current lifecycle and webhook/reporting decision rules are captured in [`../docs/spec-hardening.md`](../docs/spec-hardening.md). +The current chain-adapter boundary is documented in [`../docs/chain-adapter.md`](../docs/chain-adapter.md). +The delivery model is documented in [`../docs/webhook-delivery.md`](../docs/webhook-delivery.md). + +Current scope: + +- `GET /health` +- `POST /travel-rule` +- `GET /travel-rule/:recordId` +- `PUT /travel-rule/:recordId` +- `POST /travel-rule/:recordId/callback` +- `GET /travel-rule/search` +- `GET /travel-rule/stats` +- `POST /instruction/quote` +- `POST /instruction` +- `GET /instruction/:instructionId` +- `DELETE /instruction/:instructionId` +- `GET /instruction/search` +- `POST /instruction/:instructionId/return` (Tom v1.2) +- `POST /instruction/:instructionId/reverse` (Tom v1.2) +- `GET /instruction/:instructionId/reversal-status` (Tom v1.2) +- `GET /execution-status/:instructionId` +- `GET /execution-status/uetr/:uetr` +- `GET /finality-receipt/:instructionId` +- `GET /finality-receipt/uetr/:uetr` +- `GET /event-outbox` +- `GET /event-outbox/:eventId` +- `POST /exceptions/investigations` +- `PATCH /exceptions/investigations/:caseId` +- `GET /exceptions/investigations` +- `GET /exceptions/investigations/:caseId` +- `POST /exceptions/returns` +- `PATCH /exceptions/returns/:returnCaseId` +- `GET /exceptions/returns` +- `GET /exceptions/returns/:returnCaseId` +- `POST /webhook-endpoints` +- `GET /webhook-endpoints` +- `GET /webhook-endpoints/:subscriptionId` +- `GET /webhook-endpoints/:subscriptionId/deliveries` +- `GET /webhook-deliveries` +- `GET /webhook-deliveries/stats` +- `GET /webhook-deliveries/dead-letter` +- `GET /webhook-deliveries/:deliveryId` +- `POST /webhook-deliveries/dispatch` +- `GET /reporting/notifications` +- `GET /reporting/notifications/:notificationId` +- `GET /reporting/intraday` +- `GET /reporting/statements` +- `GET /reporting/statements/:statementId` +- `POST /report/query` +- `GET /report/intraday` +- `GET /report/statement` +- `GET /report/notification/:notificationId` +- `GET /report/search` +- `GET /report/stats` + +## Run + +```bash +npm install +npm start +``` + +Server defaults: + +- host: `127.0.0.1` +- port: `5050` +- database: `reference-server/data/reference-stack.sqlite` + +Environment overrides: + +- `REF_SERVER_HOST` +- `REF_SERVER_PORT` +- `REF_SERVER_DB_PATH` +- `REF_SERVER_WEBHOOK_AUTO_DISPATCH` +- `REF_SERVER_WEBHOOK_DISPATCH_INTERVAL_MS` +- `REF_SERVER_WEBHOOK_DISPATCH_BATCH_SIZE` +- `REF_SERVER_WEBHOOK_RETRY_SCHEDULE_MS` +- `REF_SERVER_CHAIN_ADAPTER` (`mock-evm` by default, or `sepolia-usdc`) +- `REF_SERVER_SEPOLIA_RPC_URL` +- `REF_SERVER_SEPOLIA_PRIVATE_KEY` +- `REF_SERVER_SEPOLIA_USDC_CONTRACT_ADDRESS` +- `REF_SERVER_SEPOLIA_SOURCE_ADDRESS` +- `REF_SERVER_SEPOLIA_BROADCAST_ENABLED` +- `REF_SERVER_SEPOLIA_REQUIRED_CONFIRMATIONS` +- `REF_SERVER_SEPOLIA_GAS_LIMIT` +- `REF_SERVER_SEPOLIA_MAX_FEE_GWEI` +- `REF_SERVER_SEPOLIA_MAX_PRIORITY_FEE_GWEI` + +Sepolia execution is opt-in. The mock adapter remains the default. To enable +the Sepolia adapter in read-only mode, set `REF_SERVER_CHAIN_ADAPTER=sepolia-usdc`. +To broadcast transactions, also set `REF_SERVER_SEPOLIA_BROADCAST_ENABLED=true`, +`REF_SERVER_SEPOLIA_RPC_URL`, `REF_SERVER_SEPOLIA_PRIVATE_KEY`, +`REF_SERVER_SEPOLIA_SOURCE_ADDRESS`, and +`REF_SERVER_SEPOLIA_USDC_CONTRACT_ADDRESS`. + +## Real-Chain Workflow + +Use these commands when you want to run the first funded-wallet Sepolia proof +path rather than the mock wedge. + +### 1. Preflight the wallet and RPC + +```bash +cd reference-server +npm run preflight:sepolia +``` + +Required environment for preflight: + +- `REF_SERVER_SEPOLIA_RPC_URL` +- `REF_SERVER_SEPOLIA_PRIVATE_KEY` +- `REF_SERVER_SEPOLIA_USDC_CONTRACT_ADDRESS` +- optional but recommended: `REF_SERVER_SEPOLIA_SOURCE_ADDRESS` +- optional: `REF_SERVER_DEMO_AMOUNT` (defaults to `1.00`) + +The preflight checks: + +- the RPC is actually Sepolia (`chain_id = 11155111`) +- the private key matches the configured source address +- code exists at the configured USDC contract +- the source wallet has ETH for gas and enough USDC for `REF_SERVER_DEMO_AMOUNT` + +### 2. Start the server in broadcast mode + +```bash +REF_SERVER_CHAIN_ADAPTER=sepolia-usdc \ +REF_SERVER_SEPOLIA_BROADCAST_ENABLED=true \ +npm start +``` + +### 3. Run the canonical demo flow and capture evidence + +```bash +REF_SERVER_DEMO_RECIPIENT_WALLET=0x... \ +REF_SERVER_DEMO_DEBTOR_WALLET="$REF_SERVER_SEPOLIA_SOURCE_ADDRESS" \ +npm run demo:sepolia +``` + +Optional demo controls: + +- `REF_SERVER_DEMO_BASE_URL` (defaults to `http://127.0.0.1:5050`) +- `REF_SERVER_DEMO_AMOUNT` (defaults to `1.00`) +- `REF_SERVER_DEMO_POLL_INTERVAL_MS` +- `REF_SERVER_DEMO_TIMEOUT_MS` +- `REF_SERVER_DEMO_LABEL` +- `REF_SERVER_DEMO_OUTPUT_DIR` +- `REF_SERVER_DEMO_SEND_TRAVEL_RULE_CALLBACK` + +The demo runner writes a full artifact bundle under: + +- `reference-server/data/demo-runs//` + +The runner exits non-zero unless the captured execution status and finality +receipt are both `FINAL`, so failed or probabilistic runs are not mistaken for +reviewer evidence. + +To render a reviewer-facing markdown summary from a captured run: + +```bash +npm run demo:report -- data/demo-runs/ +``` + +Artifacts include: + +- quote request/response +- Travel Rule request/response and callback +- instruction request/response +- execution-status poll history and final payload +- finality receipt +- reporting notification and statement payloads +- report search and report stats payloads +- a final summary with the tx hash and Sepolia Etherscan URL +- a reviewer markdown summary + +## Notes + +- Persistence uses Node's built-in `node:sqlite` module. +- Instruction status progression now runs through an injected chain-adapter boundary. +- The default adapter is a mocked EVM adapter with amount-aware fee, slippage, and finality modeling over the lifecycle: + `PENDING -> BROADCAST -> CONFIRMING -> FINAL` +- A `sepolia-usdc` adapter is available behind `REF_SERVER_CHAIN_ADAPTER`. It uses ethers for Sepolia RPC reads, USDC transfer broadcast, transaction receipt polling, and confirmation-depth based finality. +- The Sepolia adapter now fails safely if the configured RPC endpoint is not actually Sepolia. +- Adapter metadata is surfaced on quote, instruction, execution-status, and finality reads without adding new execution families. +- `execution-status` is the pacs.002-like read surface for lifecycle state and history. +- `finality-receipt` is the camt.025-like read surface for transaction hash, confirmations, and finality proof. +- `event-outbox` is the webhook-style delivery mirror. Event payloads are the same objects returned by `execution-status` and `finality-receipt`, so push and poll stay aligned. +- Webhook deliveries are HMAC-signed with `x-pacscrypto-signature` over `.`, plus delivery and event ids in headers. +- Delivery retries are persisted with `PENDING`, `RETRYING`, `DELIVERED`, and `FAILED` states. Background dispatch is enabled by default in the server process, and dispatch can still be forced manually via `POST /webhook-deliveries/dispatch`. +- Exhausted deliveries now carry explicit dead-letter fields and can be inspected through `GET /webhook-deliveries/stats` and `GET /webhook-deliveries/dead-letter`. +- `reporting/notifications` is the first reporting-family surface: a `camt.054` analogue for booked debtor debit and creditor credit notifications keyed to the instruction lifecycle. +- `reporting/intraday` is the next reporting-family surface: a narrow `camt.052` analogue summarizing booked intraday movements and account views from those notifications. +- `reporting/statements` starts the statement layer: a `camt.053` analogue that persists per-instruction account statements derived from the existing reporting notifications and instruction context. +- The root `Spec 3` reporting paths (`/report/*`) are also exposed alongside the legacy `/reporting/*` aliases, and the pull reads now return camt-style `group_header` / `report` / `statement` / `entry` wrappers rather than the earlier internal-only record shapes. +- Reporting records now carry explicit traceability back to instruction, status, finality, transaction hash, and Travel Rule resources where available, plus statement derivation metadata sourced from booked notifications. +- Reporting notifications are also emitted as `reporting_notification.created` events through the same outbox and webhook delivery pipeline. Generic webhook subscriptions receive the event envelope; `/report/query` notification subscriptions receive the raw camt-style notification body. +- `POST /report/query` with `STATEMENT + callback_url` now queues a raw camt-style statement callback through the same retrying delivery engine instead of inventing a separate callback subsystem. +- `exceptions/investigations` is the first exception-family runtime slice: a `camt.029`-like investigation case object linked to instruction, finality, reporting, and Travel Rule references without rewriting the original payment state. +- `exceptions/returns` is the second exception-family runtime slice: a `pacs.004`-like remediation object for post-settlement return or refund handling, again linked to rather than overwriting the original payment. +- Tom v1.2 return/reversal reconciliation is layered on top of the existing exception family. `POST /instruction/{instructionId}/return` materializes a real compensating instruction (retrievable via `GET /instruction/{instructionId}`) and a Tom-origin return case (`exception_type=RETURN`, `origin=TOM`, `return_status=APPROVED`) while returning the v1.2 `CompensatingInstructionResponse` shape (`status=PENDING`, `accepted_at`, `compensating_uetr`). `POST /instruction/{instructionId}/reverse` records a reversal request as an exception-family case with `exception_type=REVERSAL` and `status=REQUESTED` only — no compensating instruction is created at REQUESTED. `GET /instruction/{instructionId}/reversal-status` returns the most recent REVERSAL case for the original instruction. On-chain final transfers are compensated, not literally unwound. `webhook_url` is only accepted on `ReversalRequest`. Reversal cases are filtered out of the legacy `/exceptions/returns` list and detail surfaces. +- Exception-family changes are emitted as `investigation_case.updated` and `return_case.updated` through the same outbox and webhook delivery pipeline. +- Delegated signing is intentionally not implemented in this first slice. +- The root HTML simulators support both `Demo` mode and `Live API` mode against this server. +- The adapter boundary is intentionally narrow: quote generation, fee estimates, settlement defaults, lifecycle advancement, lifecycle timestamps, and lifecycle metadata now come from the chain adapter rather than being hard-coded in route or storage logic. diff --git a/reference-server/package-lock.json b/reference-server/package-lock.json new file mode 100644 index 0000000..5722336 --- /dev/null +++ b/reference-server/package-lock.json @@ -0,0 +1,732 @@ +{ + "name": "pacs-crypto-reference-server", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "pacs-crypto-reference-server", + "version": "0.1.0", + "dependencies": { + "ethers": "^6.16.0", + "fastify": "^5.2.1" + } + }, + "node_modules/@adraffy/ens-normalize": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.10.1.tgz", + "integrity": "sha512-96Z2IP3mYmF1Xg2cDm8f1gWGf/HUVedQ3FMifV4kG/PQ4yEP51xDtRAEfhVNt5f/uzpNkZHwWQuUcu6D6K+Ekw==", + "license": "MIT" + }, + "node_modules/@fastify/ajv-compiler": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz", + "integrity": "sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^3.0.0" + } + }, + "node_modules/@fastify/error": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz", + "integrity": "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/fast-json-stringify-compiler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-5.0.3.tgz", + "integrity": "sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fast-json-stringify": "^6.0.0" + } + }, + "node_modules/@fastify/forwarded": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@fastify/forwarded/-/forwarded-3.0.1.tgz", + "integrity": "sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/merge-json-schemas": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz", + "integrity": "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@fastify/proxy-addr": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz", + "integrity": "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/forwarded": "^3.0.0", + "ipaddr.js": "^2.1.0" + } + }, + "node_modules/@noble/curves": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz", + "integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.3.2" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz", + "integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.7.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.5.tgz", + "integrity": "sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.19.2" + } + }, + "node_modules/abstract-logging": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", + "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", + "license": "MIT" + }, + "node_modules/aes-js": { + "version": "4.0.0-beta.5", + "resolved": "https://registry.npmjs.org/aes-js/-/aes-js-4.0.0-beta.5.tgz", + "integrity": "sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==", + "license": "MIT" + }, + "node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/avvio": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/avvio/-/avvio-9.2.0.tgz", + "integrity": "sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/error": "^4.0.0", + "fastq": "^1.17.1" + } + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/ethers": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/ethers/-/ethers-6.16.0.tgz", + "integrity": "sha512-U1wulmetNymijEhpSEQ7Ct/P/Jw9/e7R1j5XIbPRydgV2DjLVMsULDlNksq3RQnFgKoLlZf88ijYtWEXcPa07A==", + "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" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/fast-decode-uri-component": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", + "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-json-stringify": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-6.3.0.tgz", + "integrity": "sha512-oRCntNDY/329HJPlmdNLIdogNtt6Vyjb1WuT01Soss3slIdyUp8kAcDU3saQTOquEK8KFVfwIIF7FebxUAu+yA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/merge-json-schemas": "^0.2.0", + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^3.0.0", + "json-schema-ref-resolver": "^3.0.0", + "rfdc": "^1.2.0" + } + }, + "node_modules/fast-querystring": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", + "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", + "license": "MIT", + "dependencies": { + "fast-decode-uri-component": "^1.0.1" + } + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastify": { + "version": "5.8.5", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.8.5.tgz", + "integrity": "sha512-Yqptv59pQzPgQUSIm87hMqHJmdkb1+GPxdE6vW6FRyVE9G86mt7rOghitiU4JHRaTyDUk9pfeKmDeu70lAwM4Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/ajv-compiler": "^4.0.5", + "@fastify/error": "^4.0.0", + "@fastify/fast-json-stringify-compiler": "^5.0.0", + "@fastify/proxy-addr": "^5.0.0", + "abstract-logging": "^2.0.1", + "avvio": "^9.0.0", + "fast-json-stringify": "^6.0.0", + "find-my-way": "^9.0.0", + "light-my-request": "^6.0.0", + "pino": "^9.14.0 || ^10.1.0", + "process-warning": "^5.0.0", + "rfdc": "^1.3.1", + "secure-json-parse": "^4.0.0", + "semver": "^7.6.0", + "toad-cache": "^3.7.0" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/find-my-way": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.5.0.tgz", + "integrity": "sha512-VW2RfnmscZO5KgBY5XVyKREMW5nMZcxDy+buTOsL+zIPnBlbKm+00sgzoQzq1EVh4aALZLfKdwv6atBGcjvjrQ==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-querystring": "^1.0.0", + "safe-regex2": "^5.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/ipaddr.js": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz", + "integrity": "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/json-schema-ref-resolver": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", + "integrity": "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/light-my-request": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz", + "integrity": "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause", + "dependencies": { + "cookie": "^1.0.1", + "process-warning": "^4.0.0", + "set-cookie-parser": "^2.6.0" + } + }, + "node_modules/light-my-request/node_modules/process-warning": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.1.tgz", + "integrity": "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/pino": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ret": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", + "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, + "node_modules/safe-regex2": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.1.tgz", + "integrity": "sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ret": "~0.5.0" + }, + "bin": { + "safe-regex2": "bin/safe-regex2.js" + } + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/secure-json-parse": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/thread-stream": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz", + "integrity": "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==", + "license": "MIT", + "dependencies": { + "real-require": "^0.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/toad-cache": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.0.tgz", + "integrity": "sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/tslib": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==", + "license": "0BSD" + }, + "node_modules/undici-types": { + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "license": "MIT" + }, + "node_modules/ws": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/reference-server/package.json b/reference-server/package.json new file mode 100644 index 0000000..b0380ee --- /dev/null +++ b/reference-server/package.json @@ -0,0 +1,18 @@ +{ + "name": "pacs-crypto-reference-server", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "node --watch src/server.js", + "start": "node src/server.js", + "test": "node --test", + "preflight:sepolia": "node scripts/sepolia-preflight.mjs", + "demo:sepolia": "node scripts/run-sepolia-demo.mjs", + "demo:report": "node scripts/render-demo-evidence.mjs" + }, + "dependencies": { + "ethers": "^6.16.0", + "fastify": "^5.2.1" + } +} diff --git a/reference-server/scripts/render-demo-evidence.mjs b/reference-server/scripts/render-demo-evidence.mjs new file mode 100644 index 0000000..349a30f --- /dev/null +++ b/reference-server/scripts/render-demo-evidence.mjs @@ -0,0 +1,169 @@ +import { readdir, readFile, stat, writeFile } from 'node:fs/promises'; +import { resolve, dirname, basename } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const moduleDir = dirname(fileURLToPath(import.meta.url)); +const projectRoot = resolve(moduleDir, '..'); +const defaultRunsRoot = resolve(projectRoot, 'data', 'demo-runs'); + +function hasText(value) { + return typeof value === 'string' && value.trim().length > 0; +} + +async function readJson(targetPath) { + const raw = await readFile(targetPath, 'utf8'); + return JSON.parse(raw); +} + +async function findLatestRunDir(runsRoot) { + const entries = await readdir(runsRoot, { withFileTypes: true }); + const directories = []; + + for (const entry of entries) { + if (!entry.isDirectory()) { + continue; + } + + const targetPath = resolve(runsRoot, entry.name); + const targetStat = await stat(targetPath); + directories.push({ + name: entry.name, + path: targetPath, + modifiedMs: targetStat.mtimeMs, + }); + } + + directories.sort((left, right) => right.modifiedMs - left.modifiedMs); + return directories[0]?.path ?? null; +} + +function formatMaybe(value, fallback = 'n/a') { + return hasText(value) ? value : fallback; +} + +function buildEvidenceMarkdown({ + runDir, + summary, + finalityReceipt, + executionStatus, + notificationDetail, + statementDetail, +}) { + const lines = [ + '# Reviewer Evidence Summary', + '', + `Run: \`${summary.run_id ?? basename(runDir)}\``, + '', + '## Evidence', + '', + `- Final status: \`${formatMaybe(summary.final_status)}\``, + `- Instruction ID: \`${formatMaybe(summary.instruction_id)}\``, + `- UETR: \`${formatMaybe(summary.uetr)}\``, + `- Travel Rule record: \`${formatMaybe(summary.travel_rule_record_id)}\``, + `- Transaction hash: \`${formatMaybe(summary.transaction_hash)}\``, + `- Explorer: ${summary.transaction_explorer_url ?? 'n/a'}`, + `- Finality status: \`${formatMaybe(summary.finality_status)}\``, + `- Confirmation depth: \`${summary.confirmation_depth ?? 'n/a'} / ${summary.required_confirmation_depth ?? 'n/a'}\``, + '', + '## Reporting', + '', + `- Reporting notifications: \`${summary.reporting_notification_count ?? 'n/a'}\``, + `- Reporting statements: \`${summary.reporting_statement_count ?? 'n/a'}\``, + `- Notification ID: \`${formatMaybe(notificationDetail?.notification_id)}\``, + `- Statement ID: \`${formatMaybe(statementDetail?.statement_id)}\``, + '', + '## What This Run Proves', + '', + '- Travel Rule, instruction, execution status, finality receipt, and reporting records all share the same identifiers.', + '- A real or real-ready chain adapter path can surface transaction hash, confirmation depth, and finality without changing the API family.', + '- Reporting is derived from the same payment record rather than being a disconnected explorer lookup.', + '', + '## Key Fields', + '', + `- Execution status latest state: \`${formatMaybe(executionStatus?.status)}\``, + `- Finality receipt observed at: \`${formatMaybe(finalityReceipt?.observed_at)}\``, + `- Finality receipt final at: \`${formatMaybe(finalityReceipt?.final_at)}\``, + `- Reporting account wallet: \`${formatMaybe(notificationDetail?.account?.identification?.proxy?.identification)}\``, + `- Reporting entry status: \`${formatMaybe(notificationDetail?.entry?.entry_status)}\``, + '', + '## Artifact Files', + '', + '- `20-summary.json`', + '- `11-execution-status.final.response.json`', + '- `13-finality-receipt.response.json`', + '- `15-reporting-notification.detail.response.json`', + '- `17-reporting-statement.detail.response.json`', + '', + `Generated from: \`${runDir}\``, + '', + ]; + + return `${lines.join('\n')}`; +} + +async function main() { + const explicitRunDir = process.argv[2]; + const envRunDir = process.env.REF_SERVER_DEMO_RUN_DIR; + const runsRoot = resolve( + process.env.REF_SERVER_DEMO_OUTPUT_DIR ?? defaultRunsRoot, + ); + const runDir = explicitRunDir + ? resolve(explicitRunDir) + : hasText(envRunDir) + ? resolve(envRunDir) + : await findLatestRunDir(runsRoot); + + if (!runDir) { + throw new Error('No demo run directory found.'); + } + + const summary = await readJson(resolve(runDir, '20-summary.json')); + const executionStatus = await readJson( + resolve(runDir, '11-execution-status.final.response.json'), + ); + const finalityReceipt = await readJson( + resolve(runDir, '13-finality-receipt.response.json'), + ); + + let notificationDetail = null; + try { + notificationDetail = await readJson( + resolve(runDir, '15-reporting-notification.detail.response.json'), + ); + } catch {} + + let statementDetail = null; + try { + statementDetail = await readJson( + resolve(runDir, '17-reporting-statement.detail.response.json'), + ); + } catch {} + + const markdown = buildEvidenceMarkdown({ + runDir, + summary, + finalityReceipt, + executionStatus, + notificationDetail, + statementDetail, + }); + const outputPath = resolve(runDir, '21-reviewer-summary.md'); + await writeFile(outputPath, markdown, 'utf8'); + + console.log( + JSON.stringify( + { + run_dir: runDir, + summary_file: resolve(runDir, '20-summary.json'), + reviewer_summary_file: outputPath, + }, + null, + 2, + ), + ); +} + +main().catch((error) => { + console.error(error.message); + process.exitCode = 1; +}); diff --git a/reference-server/scripts/run-sepolia-demo.mjs b/reference-server/scripts/run-sepolia-demo.mjs new file mode 100644 index 0000000..b1ca2dc --- /dev/null +++ b/reference-server/scripts/run-sepolia-demo.mjs @@ -0,0 +1,564 @@ +import { mkdir, writeFile } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const moduleDir = dirname(fileURLToPath(import.meta.url)); +const projectRoot = resolve(moduleDir, '..'); +const DEFAULT_BASE_URL = 'http://127.0.0.1:5050'; +const DEFAULT_OUTPUT_DIR = resolve(projectRoot, 'data', 'demo-runs'); +const DEFAULT_AMOUNT = '1.00'; +const DEFAULT_POLL_INTERVAL_MS = 10_000; +const DEFAULT_TIMEOUT_MS = 300_000; +const CHAIN_DLI = 'X9J9XDMTD'; +const TOKEN_DTI = 'T9B3X8H2K'; + +function hasText(value) { + return typeof value === 'string' && value.trim().length > 0; +} + +function normalizeHex(value) { + if (!hasText(value)) { + return null; + } + + const normalized = value.trim(); + return normalized.startsWith('0x') ? normalized : `0x${normalized}`; +} + +function parsePositiveInteger(value, fallback) { + const parsed = Number.parseInt(value ?? '', 10); + return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; +} + +function parseBoolean(value, fallback) { + if (value === undefined) { + return fallback; + } + + const normalized = String(value).trim().toLowerCase(); + if (['1', 'true', 'yes', 'on'].includes(normalized)) { + return true; + } + if (['0', 'false', 'no', 'off'].includes(normalized)) { + return false; + } + + return fallback; +} + +function requireEnv(name, fallback = null) { + const value = process.env[name] ?? fallback; + if (!hasText(value)) { + throw new Error(`${name} must be set.`); + } + + return value.trim(); +} + +function buildBaseUrl(value) { + return value.endsWith('/') ? value : `${value}/`; +} + +function buildRunId() { + return new Date().toISOString().replace(/[:.]/g, '-'); +} + +function buildExplorerUrl(transactionHash) { + return transactionHash + ? `https://sepolia.etherscan.io/tx/${transactionHash}` + : null; +} + +function sleep(ms) { + return new Promise((resolveDelay) => { + setTimeout(resolveDelay, ms); + }); +} + +async function writeJson(targetPath, value) { + await mkdir(dirname(targetPath), { recursive: true }); + await writeFile(targetPath, `${JSON.stringify(value, null, 2)}\n`, 'utf8'); +} + +async function requestJson(baseUrl, path, { method = 'GET', payload } = {}) { + const response = await fetch(new URL(path.replace(/^\//, ''), baseUrl), { + method, + headers: payload ? { 'content-type': 'application/json' } : undefined, + body: payload ? JSON.stringify(payload) : undefined, + }); + const text = await response.text(); + let json = null; + if (text) { + try { + json = JSON.parse(text); + } catch { + json = { raw_body: text }; + } + } + return { + status: response.status, + json, + }; +} + +function assertOk(response, context) { + if (response.status < 200 || response.status >= 300) { + throw new Error( + `${context} failed with HTTP ${response.status}: ${ + response.json?.message ?? response.json?.error ?? JSON.stringify(response.json) + }`, + ); + } +} + +function buildTravelRuleSubmission({ + amount, + debtorWallet, + recipientWallet, + runId, +}) { + return { + submission_timing: 'PRE_TX', + travel_rule_data: { + payment_identification: { + end_to_end_identification: `E2E-SEPOLIA-${runId}`, + }, + interbank_settlement_amount: { + amount, + currency: 'USD', + }, + charge_bearer: 'SHAR', + debtor: { + name: 'Acme Trading GmbH', + postal_address: { + country: 'DE', + }, + }, + debtor_account: { + proxy: { + identification: debtorWallet, + }, + }, + debtor_agent: { + name: 'Bankhaus Example AG', + lei: '7245007VX57GR4IUVZ79', + }, + creditor: { + name: 'Bravo Supplies B.V.', + postal_address: { + country: 'NL', + }, + }, + creditor_account: { + proxy: { + identification: recipientWallet, + }, + }, + creditor_agent: { + name: 'Kraken Belgium BVBA', + lei: '635400DUFB71VFOHVB49', + }, + counterparty_wallet_type: 'HOSTED', + blockchain_settlement: { + primary_chain_id: `DLID/${CHAIN_DLI}`, + legs: [{ leg_type: 'ORIGINATION' }], + }, + }, + }; +} + +function buildTravelRuleCallback() { + return { + callback_status: 'ACCEPTED', + receiving_vasp: { + name: 'Kraken Belgium BVBA', + lei: '635400DUFB71VFOHVB49', + }, + callback_timestamp: new Date().toISOString(), + description: 'Structured Travel Rule payload accepted for testnet execution.', + }; +} + +function buildQuoteRequest(amount) { + return { + token: { + token_symbol: 'USDC', + token_dti: TOKEN_DTI, + }, + chain_dli: CHAIN_DLI, + amount, + currency: 'USD', + custody_model: 'FULL_CUSTODY', + }; +} + +function buildInstructionPayload({ + amount, + debtorWallet, + recipientWallet, + quoteId, + travelRuleRecordId, + runId, +}) { + return { + payment_identification: { + end_to_end_identification: `INV-SEPOLIA-${runId}`, + quote_id: quoteId, + }, + charge_bearer: 'DEBT', + debtor: { + name: 'Acme Trading GmbH', + lei: '529900T8BM49AURSDO55', + }, + debtor_account: { + proxy: { + identification: debtorWallet, + }, + }, + debtor_agent: { + name: 'Bankhaus Example AG', + lei: '7245007VX57GR4IUVZ79', + }, + creditor: { + name: 'Bravo Supplies B.V.', + lei: '724500QHKL6MVSQQ1Z17', + }, + creditor_account: { + proxy: { + identification: recipientWallet, + }, + }, + creditor_agent: { + name: 'Kraken Belgium BVBA', + lei: '635400DUFB71VFOHVB49', + }, + interbank_settlement_amount: { + amount, + currency: 'USD', + }, + remittance_information: { + unstructured: `Sepolia reviewer demo ${runId}`, + }, + blockchain_instruction: { + token: { + token_symbol: 'USDC', + token_dti: TOKEN_DTI, + }, + chain_dli: CHAIN_DLI, + custody_model: 'FULL_CUSTODY', + }, + travel_rule_record_id: travelRuleRecordId, + }; +} + +async function collectArtifacts({ + baseUrl, + instructionId, + debtorWallet, + startedAt, +}) { + const [instructionDetail, finalityReceipt, notifications, statements, reportSearch, reportStats] = + await Promise.all([ + requestJson(baseUrl, `/instruction/${instructionId}`), + requestJson(baseUrl, `/finality-receipt/${instructionId}`), + requestJson(baseUrl, `/reporting/notifications?instruction_id=${instructionId}`), + requestJson(baseUrl, `/reporting/statements?instruction_id=${instructionId}`), + requestJson( + baseUrl, + `/report/search?wallet_address=${encodeURIComponent( + debtorWallet, + )}&chain_dli=${CHAIN_DLI}&instruction_id=${instructionId}`, + ), + requestJson( + baseUrl, + `/report/stats?wallet_address=${encodeURIComponent( + debtorWallet, + )}&chain_dli=${CHAIN_DLI}&from_date_time=${encodeURIComponent( + startedAt, + )}&to_date_time=${encodeURIComponent(new Date().toISOString())}&group_by=credit_debit`, + ), + ]); + + const notificationId = notifications.json?.notifications?.[0]?.notification_id ?? null; + const statementId = statements.json?.statements?.[0]?.statement_id ?? null; + const notificationDetail = notificationId + ? await requestJson(baseUrl, `/report/notification/${notificationId}`) + : null; + const statementDetail = statementId + ? await requestJson(baseUrl, `/reporting/statements/${statementId}`) + : null; + + return { + instruction_detail: instructionDetail, + finality_receipt: finalityReceipt, + reporting_notifications: notifications, + reporting_notification_detail: notificationDetail, + reporting_statements: statements, + reporting_statement_detail: statementDetail, + report_search: reportSearch, + report_stats: reportStats, + }; +} + +async function main() { + const baseUrl = buildBaseUrl( + process.env.REF_SERVER_DEMO_BASE_URL ?? DEFAULT_BASE_URL, + ); + const runId = process.env.REF_SERVER_DEMO_LABEL ?? buildRunId(); + const amount = process.env.REF_SERVER_DEMO_AMOUNT ?? DEFAULT_AMOUNT; + const debtorWallet = normalizeHex( + requireEnv( + 'REF_SERVER_DEMO_DEBTOR_WALLET', + process.env.REF_SERVER_SEPOLIA_SOURCE_ADDRESS, + ), + ); + const recipientWallet = normalizeHex( + requireEnv('REF_SERVER_DEMO_RECIPIENT_WALLET'), + ); + const sendTravelRuleCallback = parseBoolean( + process.env.REF_SERVER_DEMO_SEND_TRAVEL_RULE_CALLBACK, + true, + ); + const pollIntervalMs = parsePositiveInteger( + process.env.REF_SERVER_DEMO_POLL_INTERVAL_MS, + DEFAULT_POLL_INTERVAL_MS, + ); + const timeoutMs = parsePositiveInteger( + process.env.REF_SERVER_DEMO_TIMEOUT_MS, + DEFAULT_TIMEOUT_MS, + ); + const outputRoot = resolve( + process.env.REF_SERVER_DEMO_OUTPUT_DIR ?? DEFAULT_OUTPUT_DIR, + runId, + ); + const startedAt = new Date().toISOString(); + + await mkdir(outputRoot, { recursive: true }); + + const health = await requestJson(baseUrl, '/health'); + assertOk(health, 'Health check'); + await writeJson(resolve(outputRoot, '01-health.response.json'), health.json); + + const quoteRequest = buildQuoteRequest(amount); + await writeJson(resolve(outputRoot, '02-instruction-quote.request.json'), quoteRequest); + const quoteResponse = await requestJson(baseUrl, '/instruction/quote', { + method: 'POST', + payload: quoteRequest, + }); + assertOk(quoteResponse, 'Instruction quote'); + await writeJson( + resolve(outputRoot, '03-instruction-quote.response.json'), + quoteResponse.json, + ); + + const adapterMetadata = quoteResponse.json?.adapter_metadata ?? {}; + if (adapterMetadata.adapter_id !== 'sepolia-usdc') { + throw new Error( + `Server is running adapter ${adapterMetadata.adapter_id ?? 'unknown'}, expected sepolia-usdc.`, + ); + } + if (adapterMetadata.adapter_mode !== 'TESTNET_BROADCAST') { + throw new Error( + `Server adapter mode is ${adapterMetadata.adapter_mode ?? 'unknown'}, expected TESTNET_BROADCAST.`, + ); + } + + const travelRuleSubmission = buildTravelRuleSubmission({ + amount, + debtorWallet, + recipientWallet, + runId, + }); + await writeJson( + resolve(outputRoot, '04-travel-rule-submit.request.json'), + travelRuleSubmission, + ); + const travelRuleResponse = await requestJson(baseUrl, '/travel-rule', { + method: 'POST', + payload: travelRuleSubmission, + }); + assertOk(travelRuleResponse, 'Travel Rule submission'); + await writeJson( + resolve(outputRoot, '05-travel-rule-submit.response.json'), + travelRuleResponse.json, + ); + + let travelRuleCallbackResponse = null; + if (sendTravelRuleCallback) { + const callbackPayload = buildTravelRuleCallback(); + await writeJson( + resolve(outputRoot, '06-travel-rule-callback.request.json'), + callbackPayload, + ); + travelRuleCallbackResponse = await requestJson( + baseUrl, + `/travel-rule/${travelRuleResponse.json.record_id}/callback`, + { + method: 'POST', + payload: callbackPayload, + }, + ); + assertOk(travelRuleCallbackResponse, 'Travel Rule callback'); + await writeJson( + resolve(outputRoot, '07-travel-rule-callback.response.json'), + travelRuleCallbackResponse.json, + ); + } + + const instructionPayload = buildInstructionPayload({ + amount, + debtorWallet, + recipientWallet, + quoteId: quoteResponse.json.quote_id, + travelRuleRecordId: travelRuleResponse.json.record_id, + runId, + }); + await writeJson( + resolve(outputRoot, '08-instruction-submit.request.json'), + instructionPayload, + ); + const instructionResponse = await requestJson(baseUrl, '/instruction', { + method: 'POST', + payload: instructionPayload, + }); + assertOk(instructionResponse, 'Instruction submission'); + await writeJson( + resolve(outputRoot, '09-instruction-submit.response.json'), + instructionResponse.json, + ); + + const instructionId = instructionResponse.json.instruction_id; + const pollEvents = []; + const deadline = Date.now() + timeoutMs; + let finalExecutionStatus = null; + while (Date.now() < deadline) { + const executionStatus = await requestJson( + baseUrl, + `/execution-status/${instructionId}`, + ); + assertOk(executionStatus, 'Execution status'); + pollEvents.push({ + observed_at: new Date().toISOString(), + payload: executionStatus.json, + }); + + if ( + ['FINAL', 'FAILED', 'CANCELLED', 'EXPIRED', 'SLIPPAGE_EXCEEDED', 'RAMP_FAILED'].includes( + executionStatus.json.status, + ) + ) { + finalExecutionStatus = executionStatus.json; + break; + } + + await sleep(pollIntervalMs); + } + + if (!finalExecutionStatus) { + throw new Error( + `Timed out waiting for terminal status after ${timeoutMs}ms.`, + ); + } + + await writeJson( + resolve(outputRoot, '10-execution-status.polls.json'), + pollEvents, + ); + await writeJson( + resolve(outputRoot, '11-execution-status.final.response.json'), + finalExecutionStatus, + ); + + const artifacts = await collectArtifacts({ + baseUrl, + instructionId, + debtorWallet, + startedAt, + }); + + assertOk(artifacts.instruction_detail, 'Instruction detail'); + assertOk(artifacts.finality_receipt, 'Finality receipt'); + assertOk(artifacts.reporting_notifications, 'Reporting notifications'); + assertOk(artifacts.reporting_statements, 'Reporting statements'); + assertOk(artifacts.report_search, 'Report search'); + assertOk(artifacts.report_stats, 'Report stats'); + + await writeJson( + resolve(outputRoot, '12-instruction-detail.response.json'), + artifacts.instruction_detail.json, + ); + await writeJson( + resolve(outputRoot, '13-finality-receipt.response.json'), + artifacts.finality_receipt.json, + ); + await writeJson( + resolve(outputRoot, '14-reporting-notifications.response.json'), + artifacts.reporting_notifications.json, + ); + if (artifacts.reporting_notification_detail) { + await writeJson( + resolve(outputRoot, '15-reporting-notification.detail.response.json'), + artifacts.reporting_notification_detail.json, + ); + } + await writeJson( + resolve(outputRoot, '16-reporting-statements.response.json'), + artifacts.reporting_statements.json, + ); + if (artifacts.reporting_statement_detail) { + await writeJson( + resolve(outputRoot, '17-reporting-statement.detail.response.json'), + artifacts.reporting_statement_detail.json, + ); + } + await writeJson( + resolve(outputRoot, '18-report-search.response.json'), + artifacts.report_search.json, + ); + await writeJson( + resolve(outputRoot, '19-report-stats.response.json'), + artifacts.report_stats.json, + ); + + const summary = { + run_id: runId, + started_at: startedAt, + completed_at: new Date().toISOString(), + output_dir: outputRoot, + instruction_id: instructionId, + uetr: instructionResponse.json.uetr, + travel_rule_record_id: travelRuleResponse.json.record_id, + final_status: finalExecutionStatus.status, + transaction_hash: finalExecutionStatus.transaction_hash ?? null, + transaction_explorer_url: buildExplorerUrl( + finalExecutionStatus.transaction_hash ?? null, + ), + finality_status: artifacts.finality_receipt.json.finality_status ?? null, + confirmation_depth: artifacts.finality_receipt.json.confirmation_depth ?? null, + required_confirmation_depth: + artifacts.finality_receipt.json.required_confirmation_depth ?? null, + reporting_notification_count: + artifacts.reporting_notifications.json.total_matched ?? null, + reporting_statement_count: + artifacts.reporting_statements.json.total_matched ?? null, + }; + summary.evidence_valid = + summary.final_status === 'FINAL' && + summary.finality_status === 'FINAL' && + hasText(summary.transaction_hash); + + await writeJson(resolve(outputRoot, '20-summary.json'), summary); + + console.log(JSON.stringify(summary, null, 2)); + + if (!summary.evidence_valid) { + throw new Error( + `Sepolia demo evidence is not valid: execution status ${summary.final_status ?? 'unknown'}, finality ${summary.finality_status ?? 'unknown'}.`, + ); + } +} + +main().catch((error) => { + console.error(error.message); + process.exitCode = 1; +}); diff --git a/reference-server/scripts/sepolia-preflight.mjs b/reference-server/scripts/sepolia-preflight.mjs new file mode 100644 index 0000000..055feb3 --- /dev/null +++ b/reference-server/scripts/sepolia-preflight.mjs @@ -0,0 +1,186 @@ +import { Contract, JsonRpcProvider, Wallet, formatEther, formatUnits, parseUnits } from 'ethers'; + +const EXPECTED_CHAIN_ID = 11155111n; +const DEFAULT_GAS_LIMIT = 85000; +const DEFAULT_MAX_FEE_GWEI = '35'; +const DEFAULT_MAX_PRIORITY_FEE_GWEI = '2'; +const DEFAULT_DEMO_AMOUNT = '1.00'; +const ERC20_ABI = [ + 'function balanceOf(address owner) view returns (uint256)', + 'function decimals() view returns (uint8)', + 'function symbol() view returns (string)', +]; + +function hasText(value) { + return typeof value === 'string' && value.trim().length > 0; +} + +function normalizeHex(value) { + if (!hasText(value)) { + return null; + } + + const normalized = value.trim(); + return normalized.startsWith('0x') ? normalized : `0x${normalized}`; +} + +function parsePositiveInteger(value, fallback) { + const parsed = Number.parseInt(value ?? '', 10); + return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; +} + +function parseDemoAmount(value) { + const normalized = String(value ?? DEFAULT_DEMO_AMOUNT).trim(); + if (!/^\d+(\.\d+)?$/.test(normalized) || Number.parseFloat(normalized) <= 0) { + throw new Error('REF_SERVER_DEMO_AMOUNT must be a positive decimal amount.'); + } + + return normalized; +} + +function requireEnv(name) { + const value = process.env[name]; + if (!hasText(value)) { + throw new Error(`${name} must be set.`); + } + + return value.trim(); +} + +async function main() { + const rpcUrl = requireEnv('REF_SERVER_SEPOLIA_RPC_URL'); + const privateKey = requireEnv('REF_SERVER_SEPOLIA_PRIVATE_KEY'); + const usdcContractAddress = normalizeHex( + requireEnv('REF_SERVER_SEPOLIA_USDC_CONTRACT_ADDRESS'), + ); + const configuredSourceAddress = normalizeHex( + process.env.REF_SERVER_SEPOLIA_SOURCE_ADDRESS ?? '', + ); + const gasLimit = parsePositiveInteger( + process.env.REF_SERVER_SEPOLIA_GAS_LIMIT, + DEFAULT_GAS_LIMIT, + ); + const maxFeePerGasGwei = + process.env.REF_SERVER_SEPOLIA_MAX_FEE_GWEI ?? DEFAULT_MAX_FEE_GWEI; + const maxPriorityFeePerGasGwei = + process.env.REF_SERVER_SEPOLIA_MAX_PRIORITY_FEE_GWEI ?? + DEFAULT_MAX_PRIORITY_FEE_GWEI; + const demoAmount = parseDemoAmount(process.env.REF_SERVER_DEMO_AMOUNT); + + const provider = new JsonRpcProvider(rpcUrl); + const wallet = new Wallet(privateKey, provider); + const sourceAddress = wallet.address; + const contract = new Contract(usdcContractAddress, ERC20_ABI, provider); + + const [ + network, + ethBalanceWei, + contractCode, + usdcBalanceRaw, + usdcDecimals, + usdcSymbol, + ] = await Promise.all([ + provider.getNetwork(), + provider.getBalance(sourceAddress), + provider.getCode(usdcContractAddress), + contract.balanceOf(sourceAddress), + contract.decimals(), + contract.symbol(), + ]); + + const chainId = BigInt(network.chainId); + const tokenDecimals = Number(usdcDecimals); + const maxGasCostWei = parseUnits(String(maxFeePerGasGwei), 9) * BigInt(gasLimit); + const demoAmountRaw = parseUnits(demoAmount, tokenDecimals); + const recommendations = []; + let ok = true; + + if (chainId !== EXPECTED_CHAIN_ID) { + ok = false; + recommendations.push( + `RPC is on chain_id ${chainId}, but this flow expects Sepolia ${EXPECTED_CHAIN_ID}.`, + ); + } + + if ( + configuredSourceAddress && + configuredSourceAddress.toLowerCase() !== sourceAddress.toLowerCase() + ) { + ok = false; + recommendations.push( + 'Configured source address does not match the supplied private key.', + ); + } + + if (contractCode === '0x') { + ok = false; + recommendations.push( + 'No contract code found at REF_SERVER_SEPOLIA_USDC_CONTRACT_ADDRESS.', + ); + } + + if (ethBalanceWei < maxGasCostWei) { + ok = false; + recommendations.push( + 'ETH balance is below the configured maximum gas-cost envelope for one transfer.', + ); + } else if (ethBalanceWei < maxGasCostWei * 2n) { + recommendations.push( + 'ETH balance is technically sufficient, but topping it up would reduce demo risk.', + ); + } + + if (usdcBalanceRaw < demoAmountRaw) { + ok = false; + recommendations.push( + `USDC balance is below REF_SERVER_DEMO_AMOUNT (${demoAmount}); the demo transfer would fail.`, + ); + } + + const summary = { + ok, + checked_at: new Date().toISOString(), + network: { + chain_id: String(chainId), + expected_chain_id: String(EXPECTED_CHAIN_ID), + matches_expected: chainId === EXPECTED_CHAIN_ID, + name: network.name ?? null, + }, + source_wallet: { + address: sourceAddress, + configured_source_address: configuredSourceAddress, + matches_private_key: + configuredSourceAddress === null + ? null + : configuredSourceAddress.toLowerCase() === sourceAddress.toLowerCase(), + eth_balance: formatEther(ethBalanceWei), + }, + token: { + contract_address: usdcContractAddress, + symbol: usdcSymbol, + decimals: tokenDecimals, + balance: formatUnits(usdcBalanceRaw, tokenDecimals), + demo_amount: demoAmount, + has_demo_amount: usdcBalanceRaw >= demoAmountRaw, + code_present: contractCode !== '0x', + }, + gas_policy: { + gas_limit: gasLimit, + max_fee_per_gas_gwei: String(maxFeePerGasGwei), + max_priority_fee_per_gas_gwei: String(maxPriorityFeePerGasGwei), + max_gas_cost_eth: formatEther(maxGasCostWei), + }, + recommendations, + }; + + console.log(JSON.stringify(summary, null, 2)); + + if (!ok) { + process.exitCode = 1; + } +} + +main().catch((error) => { + console.error(error.message); + process.exitCode = 1; +}); diff --git a/reference-server/src/app.js b/reference-server/src/app.js new file mode 100644 index 0000000..79e8183 --- /dev/null +++ b/reference-server/src/app.js @@ -0,0 +1,126 @@ +import Fastify from 'fastify'; + +import { normalizeChainAdapter } from './chain/adapter-contract.js'; +import { ReferenceStore } from './db.js'; +import { registerExceptionRoutes } from './routes/exception-routes.js'; +import { registerEventRoutes } from './routes/event-routes.js'; +import { registerHealthRoutes } from './routes/health-routes.js'; +import { registerInstructionRoutes } from './routes/instruction-routes.js'; +import { registerReportingRoutes } from './routes/reporting-routes.js'; +import { registerStatusRoutes } from './routes/status-routes.js'; +import { registerTravelRuleRoutes } from './routes/travel-rule-routes.js'; +import { registerWebhookRoutes } from './routes/webhook-routes.js'; + +async function defaultWebhookSender({ url, headers, body }) { + const response = await fetch(url, { + method: 'POST', + headers, + body, + }); + + return { + status: response.status, + bodyText: await response.text(), + }; +} + +function normalizeWebhookDispatchConfig(config = {}) { + const intervalMs = Number.parseInt(config.intervalMs ?? '1000', 10); + const batchSize = Number.parseInt(config.batchSize ?? '20', 10); + + return { + enabled: config.enabled === true, + intervalMs: Number.isInteger(intervalMs) && intervalMs > 0 ? intervalMs : 1000, + batchSize: Number.isInteger(batchSize) && batchSize > 0 ? batchSize : 20, + }; +} + +export async function buildApp({ + dbPath = ':memory:', + chainAdapter = null, + webhookSender = defaultWebhookSender, + webhookDispatch = {}, + webhookRetryScheduleMs, +} = {}) { + const app = Fastify({ logger: false }); + const dispatchConfig = normalizeWebhookDispatchConfig(webhookDispatch); + const normalizedChainAdapter = normalizeChainAdapter(chainAdapter); + const store = new ReferenceStore({ + dbPath, + chainAdapter: normalizedChainAdapter, + webhookRetryScheduleMs, + }); + let dispatchTimer = null; + let dispatchInFlight = false; + + app.decorate('store', store); + app.decorate('chainAdapter', normalizedChainAdapter); + app.decorate('webhookSender', webhookSender); + app.decorate('dispatchDueWebhookDeliveries', async ({ limit, subscriptionId } = {}) => + store.dispatchPendingWebhookDeliveries({ + sender: app.webhookSender, + limit: limit ?? dispatchConfig.batchSize, + subscriptionId: subscriptionId ?? null, + }), + ); + + app.addHook('onRequest', async (request, reply) => { + reply.header('access-control-allow-origin', '*'); + reply.header( + 'access-control-allow-methods', + 'GET,POST,PUT,DELETE,OPTIONS', + ); + reply.header( + 'access-control-allow-headers', + 'content-type, authorization', + ); + + if (request.method === 'OPTIONS') { + reply.code(204).send(); + } + }); + + app.addHook('onReady', async () => { + if (!dispatchConfig.enabled) { + return; + } + + dispatchTimer = setInterval(async () => { + if (dispatchInFlight) { + return; + } + + dispatchInFlight = true; + try { + await app.dispatchDueWebhookDeliveries({ + limit: dispatchConfig.batchSize, + }); + } catch (error) { + app.log.error(error, 'background webhook dispatch failed'); + } finally { + dispatchInFlight = false; + } + }, dispatchConfig.intervalMs); + + dispatchTimer.unref?.(); + }); + + app.addHook('onClose', async () => { + if (dispatchTimer) { + clearInterval(dispatchTimer); + dispatchTimer = null; + } + store.close(); + }); + + registerHealthRoutes(app); + registerTravelRuleRoutes(app); + registerInstructionRoutes(app); + registerExceptionRoutes(app); + registerStatusRoutes(app); + registerEventRoutes(app); + registerWebhookRoutes(app); + registerReportingRoutes(app); + + return app; +} diff --git a/reference-server/src/chain/adapter-contract.js b/reference-server/src/chain/adapter-contract.js new file mode 100644 index 0000000..2e701c1 --- /dev/null +++ b/reference-server/src/chain/adapter-contract.js @@ -0,0 +1,24 @@ +import { createMockEvmChainAdapter } from './mock-evm-adapter.js'; + +export function normalizeChainAdapter(chainAdapter = null) { + const fallback = createMockEvmChainAdapter(); + const candidate = chainAdapter ?? {}; + + return { + ...fallback, + ...candidate, + id: + typeof candidate.id === 'string' && candidate.id.trim().length > 0 + ? candidate.id + : fallback.id, + mode: + typeof candidate.mode === 'string' && candidate.mode.trim().length > 0 + ? candidate.mode + : fallback.mode, + chain_family: + typeof candidate.chain_family === 'string' && + candidate.chain_family.trim().length > 0 + ? candidate.chain_family + : fallback.chain_family, + }; +} diff --git a/reference-server/src/chain/mock-evm-adapter.js b/reference-server/src/chain/mock-evm-adapter.js new file mode 100644 index 0000000..1844127 --- /dev/null +++ b/reference-server/src/chain/mock-evm-adapter.js @@ -0,0 +1,536 @@ +import { randomUUID } from 'node:crypto'; + +const DEFAULT_CHAIN_PROFILE = { + nativeCurrency: 'ETH', + nativeUsdPrice: 3785, + blockBaseNumber: 22190456, + blockTimeSeconds: 12, +}; + +function nowIso() { + return new Date().toISOString(); +} + +function clamp(value, min, max) { + return Math.min(Math.max(value, min), max); +} + +function hashText(value = '') { + let hash = 2166136261; + for (const char of value) { + hash ^= char.charCodeAt(0); + hash = Math.imul(hash, 16777619); + } + + return hash >>> 0; +} + +function buildMockTransactionHash(seed = randomUUID()) { + let state = hashText(seed); + let hex = ''; + + while (hex.length < 64) { + state = Math.imul(state ^ 0x9e3779b9, 1664525) + 1013904223; + hex += (state >>> 0).toString(16).padStart(8, '0'); + } + + return `0x${hex.slice(0, 64)}`; +} + +function resolveChainProfile(chainDli) { + if (chainDli === 'X9J9XDMTD') { + return DEFAULT_CHAIN_PROFILE; + } + + return DEFAULT_CHAIN_PROFILE; +} + +function resolveCongestionLevel({ + amount = 0, + rampType = 'NONE', + maximumSlippageRate = 0, +} = {}) { + let score = 0; + + if (amount >= 250000) { + score += 2; + } else if (amount >= 2500) { + score += 1; + } + + if (rampType && rampType !== 'NONE') { + score += 1; + } + + if (maximumSlippageRate > 0 && maximumSlippageRate < 0.0015) { + score += 1; + } + + if (score >= 3) { + return 'HIGH'; + } + if (score >= 1) { + return 'MEDIUM'; + } + return 'LOW'; +} + +function getCongestionProfile(level, chainProfile) { + if (level === 'HIGH') { + return { + baseFeeGwei: 33.6, + priorityFeeGwei: 2.8, + blockTimeSeconds: chainProfile.blockTimeSeconds + 2, + confirmationIntervalMs: 360, + gasLimit: 81000, + }; + } + + if (level === 'LOW') { + return { + baseFeeGwei: 11.2, + priorityFeeGwei: 1.0, + blockTimeSeconds: Math.max(chainProfile.blockTimeSeconds - 1, 10), + confirmationIntervalMs: 170, + gasLimit: 61000, + }; + } + + return { + baseFeeGwei: 19.8, + priorityFeeGwei: 1.7, + blockTimeSeconds: chainProfile.blockTimeSeconds, + confirmationIntervalMs: 250, + gasLimit: 70000, + }; +} + +function resolveConfirmationDepth({ amount = 0, rampType = 'NONE' } = {}) { + if (amount >= 250000 || rampType === 'ONRAMP_AND_OFFRAMP') { + return 18; + } + if (amount >= 1000 || rampType !== 'NONE') { + return 12; + } + return 6; +} + +function deriveBlockNumber(record, policy) { + const seed = `${record.instruction_id ?? record.payment_identification?.uetr ?? ''}:${record.created_at ?? ''}`; + return policy.chainProfile.blockBaseNumber + (hashText(seed) % 100000); +} + +function resolveSimulationPolicy(input = {}) { + const amount = Number.parseFloat( + input.amount ?? input.interbank_settlement_amount?.amount ?? '0', + ); + const rampType = + input.ramp_type ?? + input.blockchain_instruction?.ramp_instruction?.ramp_type ?? + 'NONE'; + const maximumSlippageRate = Number.parseFloat( + input.maximum_slippage_rate ?? + input.blockchain_instruction?.maximum_slippage_rate ?? + '0', + ); + const chainDli = + input.chain_dli ?? input.blockchain_instruction?.chain_dli ?? 'X9J9XDMTD'; + const chainProfile = resolveChainProfile(chainDli); + const congestionLevel = resolveCongestionLevel({ + amount, + rampType, + maximumSlippageRate, + }); + const congestionProfile = getCongestionProfile(congestionLevel, chainProfile); + const requiredConfirmationDepth = resolveConfirmationDepth({ + amount, + rampType, + }); + const gasPriceGwei = + congestionProfile.baseFeeGwei + congestionProfile.priorityFeeGwei; + const gasCostNative = + (gasPriceGwei * congestionProfile.gasLimit) / 1_000_000_000; + const serviceFee = + amount >= 250000 ? 45 : amount >= 10000 ? 24.5 : 12.5; + const rampSpreadBps = + rampType === 'NONE' + ? 0 + : rampType === 'ONRAMP_AND_OFFRAMP' + ? 45 + : 25; + const slippageEstimateBps = + rampType === 'NONE' + ? clamp(4 + (amount >= 100000 ? 3 : amount >= 10000 ? 1 : 0), 4, 9) + : clamp(12 + (amount >= 100000 ? 10 : amount >= 10000 ? 5 : 0), 12, 36); + const broadcastDelayMs = congestionLevel === 'HIGH' ? 1100 : 850; + const inclusionDelayMs = + broadcastDelayMs + + (congestionLevel === 'HIGH' ? 1500 : congestionLevel === 'MEDIUM' ? 950 : 700); + const finalityDelayMs = + inclusionDelayMs + + (requiredConfirmationDepth * congestionProfile.confirmationIntervalMs); + const estimatedConfirmationSeconds = Math.round( + requiredConfirmationDepth * congestionProfile.blockTimeSeconds, + ); + + return { + amount, + rampType, + maximumSlippageRate: Number.isFinite(maximumSlippageRate) + ? maximumSlippageRate + : 0, + chainDli, + chainProfile, + congestionLevel, + requiredConfirmationDepth, + baseFeeGwei: congestionProfile.baseFeeGwei, + priorityFeeGwei: congestionProfile.priorityFeeGwei, + blockTimeSeconds: congestionProfile.blockTimeSeconds, + confirmationIntervalMs: congestionProfile.confirmationIntervalMs, + broadcastDelayMs, + inclusionDelayMs, + finalityDelayMs, + estimatedConfirmationSeconds, + gasLimit: congestionProfile.gasLimit, + gasCostNative, + gasCostFiat: gasCostNative * chainProfile.nativeUsdPrice, + serviceFee, + rampSpreadBps, + slippageEstimateBps, + }; +} + +function buildFeeEstimate(request = {}) { + const policy = resolveSimulationPolicy(request); + + return { + gas_cost_native: { + amount: policy.gasCostNative.toFixed(6), + currency: policy.chainProfile.nativeCurrency, + }, + gas_cost_fiat: { + amount: policy.gasCostFiat.toFixed(2), + currency: 'USD', + }, + vasp_service_fee: { + amount: policy.serviceFee.toFixed(2), + currency: 'USD', + }, + ramp_spread_bps: policy.rampSpreadBps, + slippage_estimate_bps: policy.slippageEstimateBps, + total_cost_fiat: { + amount: (policy.gasCostFiat + policy.serviceFee).toFixed(2), + currency: 'USD', + }, + }; +} + +function buildPreExecutionSettlementState(settlement, policy, amount) { + return { + ...settlement, + transaction_hash: null, + block_number: null, + block_timestamp: null, + confirmation_depth: 0, + required_confirmation_depth: policy.requiredConfirmationDepth, + finality_status: 'PENDING', + actual_amount_transferred: amount ?? '0', + actual_gas_cost_native: '0.000000', + actual_gas_cost_fiat: { + amount: '0.00', + currency: 'USD', + }, + actual_slippage_rate: + policy.rampType === 'NONE' + ? '0.0000' + : (policy.slippageEstimateBps / 10000).toFixed(4), + }; +} + +function buildExecutedSettlementState({ + settlement, + policy, + transactionHash, + blockNumber, + blockTimestamp, + confirmationDepth, + finalityStatus, +}) { + const netTransferredAmount = + policy.rampType === 'NONE' + ? policy.amount + : policy.amount * (1 - (policy.slippageEstimateBps / 10000)); + + return { + ...settlement, + transaction_hash: transactionHash, + block_number: blockNumber, + block_timestamp: blockTimestamp, + confirmation_depth: confirmationDepth, + required_confirmation_depth: policy.requiredConfirmationDepth, + finality_status: finalityStatus, + actual_amount_transferred: netTransferredAmount.toFixed(2), + actual_gas_cost_native: policy.gasCostNative.toFixed(6), + actual_gas_cost_fiat: { + amount: policy.gasCostFiat.toFixed(2), + currency: 'USD', + }, + actual_slippage_rate: (policy.slippageEstimateBps / 10000).toFixed(4), + }; +} + +function buildAdapterMetadata(adapter, input = {}) { + const policy = resolveSimulationPolicy(input); + + return { + adapter_id: adapter.id ?? 'mock-evm', + adapter_mode: adapter.mode ?? 'SIMULATED', + chain_family: adapter.chain_family ?? 'EVM', + chain_dli: policy.chainDli, + settlement_model: 'PROBABILISTIC_TO_THRESHOLD_FINALITY', + simulated: (adapter.mode ?? 'SIMULATED') === 'SIMULATED', + congestion_level: policy.congestionLevel, + network_profile: { + native_currency: policy.chainProfile.nativeCurrency, + native_usd_price: policy.chainProfile.nativeUsdPrice, + reference_block_base_number: policy.chainProfile.blockBaseNumber, + }, + lifecycle_policy: { + ramp_type: policy.rampType, + maximum_slippage_rate: policy.maximumSlippageRate.toFixed(4), + required_confirmation_depth: policy.requiredConfirmationDepth, + estimated_confirmation_seconds: policy.estimatedConfirmationSeconds, + block_time_seconds: policy.blockTimeSeconds, + confirmation_interval_ms: policy.confirmationIntervalMs, + broadcast_delay_ms: policy.broadcastDelayMs, + inclusion_delay_ms: policy.inclusionDelayMs, + finality_delay_ms: policy.finalityDelayMs, + }, + fee_model: { + gas_limit: policy.gasLimit, + base_fee_gwei: policy.baseFeeGwei.toFixed(1), + priority_fee_gwei: policy.priorityFeeGwei.toFixed(1), + service_fee_usd: policy.serviceFee.toFixed(2), + ramp_spread_bps: policy.rampSpreadBps, + slippage_estimate_bps: policy.slippageEstimateBps, + }, + }; +} + +export function createMockEvmChainAdapter() { + return { + id: 'mock-evm', + mode: 'SIMULATED', + chain_family: 'EVM', + + hasExpired(expiryDateTime) { + return Boolean(expiryDateTime) && Date.parse(expiryDateTime) <= Date.now(); + }, + + buildFeeEstimate, + + describeLifecycle(input = {}) { + return buildAdapterMetadata(this, input); + }, + + buildQuoteResponse(request = {}) { + const createdAt = nowIso(); + const validUntil = new Date(Date.now() + 5 * 60 * 1000).toISOString(); + const policy = resolveSimulationPolicy(request); + + return { + quote_id: randomUUID(), + valid_until: validUntil, + fee_lock_type: policy.congestionLevel === 'HIGH' ? 'INDICATIVE' : 'CAPPED', + fee_estimate: buildFeeEstimate(request), + estimated_confirmation_seconds: policy.estimatedConfirmationSeconds, + chain_conditions: { + congestion_level: policy.congestionLevel, + current_base_fee_gwei: policy.baseFeeGwei.toFixed(1), + average_block_time_seconds: policy.blockTimeSeconds, + }, + adapter_metadata: this.describeLifecycle(request), + created_at: createdAt, + }; + }, + + normalizeOnChainSettlement(onChainSettlement, amount, input = {}) { + const policy = resolveSimulationPolicy({ + ...input, + amount, + }); + + return buildPreExecutionSettlementState( + { + required_confirmation_depth: policy.requiredConfirmationDepth, + ...onChainSettlement, + }, + policy, + amount ?? '0', + ); + }, + + getLifecycleTimestamp(record, status) { + const history = record.status_history ?? []; + for (let index = history.length - 1; index >= 0; index -= 1) { + if (history[index].status === status) { + return history[index].status_at; + } + } + + const createdAt = Date.parse(record.created_at); + if (Number.isNaN(createdAt)) { + return record.updated_at ?? nowIso(); + } + + const policy = resolveSimulationPolicy(record); + + if (['PENDING', 'QUOTED', 'SLIPPAGE_EXCEEDED', 'RAMP_FAILED'].includes(status)) { + return record.created_at; + } + if (status === 'BROADCAST') { + return new Date(createdAt + policy.broadcastDelayMs).toISOString(); + } + if (status === 'CONFIRMING') { + return new Date(createdAt + policy.inclusionDelayMs).toISOString(); + } + if (status === 'FINAL') { + return new Date(createdAt + policy.finalityDelayMs).toISOString(); + } + + return record.updated_at ?? nowIso(); + }, + + deriveLifecycleState(record) { + const policy = resolveSimulationPolicy(record); + const normalizedSettlement = this.normalizeOnChainSettlement( + record.on_chain_settlement, + record.interbank_settlement_amount?.amount, + record, + ); + const elapsedMs = Date.now() - Date.parse(record.created_at); + const transactionSeed = + record.instruction_id ?? + record.payment_identification?.uetr ?? + record.created_at ?? + randomUUID(); + const transactionHash = + normalizedSettlement.transaction_hash ?? buildMockTransactionHash(transactionSeed); + const blockNumber = deriveBlockNumber(record, policy); + const blockTimestamp = new Date( + Date.parse(record.created_at) + policy.inclusionDelayMs, + ).toISOString(); + + if (record.status === 'PENDING' && this.hasExpired(record.expiry_date_time)) { + return { + status: 'EXPIRED', + failureReason: 'Instruction expired before execution.', + onChainSettlement: buildPreExecutionSettlementState( + normalizedSettlement, + policy, + record.interbank_settlement_amount?.amount ?? '0', + ), + }; + } + + if ( + record.status === 'PENDING' && + policy.rampType !== 'NONE' && + policy.maximumSlippageRate > 0 && + policy.maximumSlippageRate < (policy.slippageEstimateBps / 10000) + ) { + return { + status: 'SLIPPAGE_EXCEEDED', + failureReason: `Estimated slippage of ${(policy.slippageEstimateBps / 100).toFixed(2)}% exceeds configured maximum slippage.`, + onChainSettlement: buildPreExecutionSettlementState( + normalizedSettlement, + policy, + record.interbank_settlement_amount?.amount ?? '0', + ), + }; + } + + if (['CANCELLED', 'EXPIRED', 'SLIPPAGE_EXCEEDED', 'RAMP_FAILED', 'FAILED'].includes(record.status)) { + return { + status: record.status, + failureReason: record.failure_reason ?? null, + onChainSettlement: + record.status === 'CANCELLED' + ? buildPreExecutionSettlementState( + normalizedSettlement, + policy, + record.interbank_settlement_amount?.amount ?? '0', + ) + : normalizedSettlement, + }; + } + + if (elapsedMs >= policy.finalityDelayMs) { + return { + status: 'FINAL', + failureReason: null, + onChainSettlement: buildExecutedSettlementState({ + settlement: normalizedSettlement, + policy, + transactionHash, + blockNumber, + blockTimestamp, + confirmationDepth: policy.requiredConfirmationDepth, + finalityStatus: 'FINAL', + }), + }; + } + + if (elapsedMs >= policy.inclusionDelayMs) { + const confirmationsEarned = + Math.floor( + (elapsedMs - policy.inclusionDelayMs) / policy.confirmationIntervalMs, + ) + 1; + + return { + status: 'CONFIRMING', + failureReason: null, + onChainSettlement: buildExecutedSettlementState({ + settlement: normalizedSettlement, + policy, + transactionHash, + blockNumber, + blockTimestamp, + confirmationDepth: clamp( + confirmationsEarned, + 1, + Math.max(policy.requiredConfirmationDepth - 1, 1), + ), + finalityStatus: 'PROBABILISTIC', + }), + }; + } + + if (elapsedMs >= policy.broadcastDelayMs) { + return { + status: 'BROADCAST', + failureReason: null, + onChainSettlement: buildExecutedSettlementState({ + settlement: normalizedSettlement, + policy, + transactionHash, + blockNumber: null, + blockTimestamp: null, + confirmationDepth: 0, + finalityStatus: 'PENDING', + }), + }; + } + + return { + status: record.status, + failureReason: record.failure_reason ?? null, + onChainSettlement: buildPreExecutionSettlementState( + normalizedSettlement, + policy, + record.interbank_settlement_amount?.amount ?? '0', + ), + }; + }, + }; +} diff --git a/reference-server/src/chain/sepolia-usdc-adapter.js b/reference-server/src/chain/sepolia-usdc-adapter.js new file mode 100644 index 0000000..06aa0b0 --- /dev/null +++ b/reference-server/src/chain/sepolia-usdc-adapter.js @@ -0,0 +1,773 @@ +import { + Contract, + Interface, + JsonRpcProvider, + Wallet, + formatEther, + formatUnits, + parseUnits, +} from 'ethers'; +import { randomUUID } from 'node:crypto'; + +const SEPOLIA_CHAIN_DLI = 'X9J9XDMTD'; +const SEPOLIA_CHAIN_ID = 11155111n; +const SEPOLIA_USDC_TOKEN_DTI = 'T9B3X8H2K'; +const DEFAULT_REQUIRED_CONFIRMATIONS = 3; +const DEFAULT_USDC_DECIMALS = 6; +const DEFAULT_GAS_LIMIT = 85000; +const DEFAULT_MAX_FEE_GWEI = '35'; +const DEFAULT_PRIORITY_FEE_GWEI = '2'; +const DEFAULT_EXPLORER_BASE_URL = 'https://sepolia.etherscan.io'; +const ERC20_ABI = [ + 'function transfer(address to, uint256 amount) returns (bool)', + 'event Transfer(address indexed from, address indexed to, uint256 value)', +]; +const ERC20_INTERFACE = new Interface(ERC20_ABI); + +function nowIso() { + return new Date().toISOString(); +} + +function hasText(value) { + return typeof value === 'string' && value.trim().length > 0; +} + +function normalizeHex(value) { + if (!hasText(value)) { + return null; + } + + const normalized = value.trim(); + return normalized.startsWith('0x') ? normalized : `0x${normalized}`; +} + +function normalizeIdentifier(value, prefix = null) { + if (!hasText(value)) { + return null; + } + + const normalized = value.trim(); + if (prefix && normalized.toUpperCase().startsWith(`${prefix}/`)) { + return normalized.slice(prefix.length + 1); + } + return normalized; +} + +function normalizeComparisonAddress(value) { + return normalizeHex(value)?.toLowerCase() ?? null; +} + +function normalizeLogAddress(value) { + return normalizeComparisonAddress(value); +} + +function normalizeInteger(value, fallback) { + const parsed = Number.parseInt(value ?? '', 10); + return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; +} + +function parseGweiToWei(value) { + return parseUnits(String(value ?? '0'), 9); +} + +function safeBigInt(value) { + if (value === undefined || value === null) { + return null; + } + + try { + return BigInt(value); + } catch { + return null; + } +} + +function getRecipientAddress(input = {}) { + return ( + normalizeHex(input.creditor_account?.proxy?.identification) ?? + normalizeHex(input.blockchain_instruction?.destination_wallet_address) ?? + null + ); +} + +function getDebtorAddress(input = {}) { + return ( + normalizeHex(input.debtor_account?.proxy?.identification) ?? + normalizeHex(input.blockchain_instruction?.source_wallet_address) ?? + null + ); +} + +function validateSepoliaUsdcCorridor({ config, record, wallet }) { + const chainDli = normalizeIdentifier( + record.blockchain_instruction?.chain_dli, + 'DLID', + ); + if (chainDli !== SEPOLIA_CHAIN_DLI) { + return `Sepolia USDC broadcast requires chain_dli ${SEPOLIA_CHAIN_DLI}.`; + } + + const token = record.blockchain_instruction?.token ?? {}; + const tokenDti = normalizeIdentifier(token.token_dti, 'DTID'); + const tokenSymbol = token.token_symbol?.toUpperCase?.() ?? null; + if (tokenDti !== SEPOLIA_USDC_TOKEN_DTI || tokenSymbol !== 'USDC') { + return `Sepolia USDC broadcast requires token_dti ${SEPOLIA_USDC_TOKEN_DTI} and token_symbol USDC.`; + } + + const settlementCurrency = record.interbank_settlement_amount?.currency ?? null; + if (settlementCurrency !== 'USD') { + return 'Sepolia USDC broadcast requires interbank_settlement_amount.currency USD.'; + } + + const debtorAddress = normalizeComparisonAddress(getDebtorAddress(record)); + const sourceAddress = normalizeComparisonAddress(config.sourceAddress ?? wallet.address); + if (!debtorAddress) { + return 'Sepolia USDC broadcast requires debtor_account.proxy.identification as source wallet address.'; + } + if (debtorAddress !== sourceAddress) { + return 'Sepolia USDC broadcast requires debtor wallet to match the configured source wallet.'; + } + + return null; +} + +function buildFallbackFeeEstimate(config = {}) { + const maxFeePerGas = parseGweiToWei(config.maxFeePerGasGwei); + const gasLimit = BigInt(config.gasLimit); + const gasCostWei = maxFeePerGas * gasLimit; + return { + gas_cost_native: { + amount: formatEther(gasCostWei), + currency: 'ETH', + }, + gas_cost_fiat: { + amount: '0.00', + currency: 'USD', + }, + vasp_service_fee: { + amount: '0.00', + currency: 'USD', + }, + ramp_spread_bps: 0, + slippage_estimate_bps: 0, + total_cost_fiat: { + amount: '0.00', + currency: 'USD', + }, + }; +} + +function normalizeConfig(config = {}) { + return { + rpcUrl: config.rpcUrl ?? null, + provider: config.provider ?? null, + walletFactory: config.walletFactory ?? null, + contractFactory: config.contractFactory ?? null, + privateKey: normalizeHex(config.privateKey), + usdcContractAddress: normalizeHex(config.usdcContractAddress), + sourceAddress: normalizeHex(config.sourceAddress), + requiredConfirmations: normalizeInteger( + config.requiredConfirmations, + DEFAULT_REQUIRED_CONFIRMATIONS, + ), + usdcDecimals: normalizeInteger(config.usdcDecimals, DEFAULT_USDC_DECIMALS), + gasLimit: normalizeInteger(config.gasLimit, DEFAULT_GAS_LIMIT), + broadcastEnabled: config.broadcastEnabled === true, + maxFeePerGasGwei: config.maxFeePerGasGwei ?? DEFAULT_MAX_FEE_GWEI, + maxPriorityFeePerGasGwei: + config.maxPriorityFeePerGasGwei ?? DEFAULT_PRIORITY_FEE_GWEI, + explorerBaseUrl: config.explorerBaseUrl ?? DEFAULT_EXPLORER_BASE_URL, + }; +} + +function buildAdapterMetadata(config, input = {}) { + return { + adapter_id: 'sepolia-usdc', + adapter_mode: config.broadcastEnabled ? 'TESTNET_BROADCAST' : 'TESTNET_READ_ONLY', + chain_family: 'EVM', + chain_dli: input.chain_dli ?? input.blockchain_instruction?.chain_dli ?? SEPOLIA_CHAIN_DLI, + settlement_model: 'SEPOLIA_TESTNET_CONFIRMATION_THRESHOLD', + simulated: false, + network_profile: { + chain_name: 'Ethereum Sepolia', + expected_chain_id: String(SEPOLIA_CHAIN_ID), + native_currency: 'ETH', + usdc_contract_address: config.usdcContractAddress, + configured_source_address: config.sourceAddress, + rpc_configured: hasText(config.rpcUrl), + explorer_base_url: config.explorerBaseUrl, + }, + lifecycle_policy: { + custody_model: + input.custody_model ?? input.blockchain_instruction?.custody_model ?? 'FULL_CUSTODY', + required_confirmation_depth: config.requiredConfirmations, + broadcast_enabled: config.broadcastEnabled, + read_only_without_private_key: !hasText(config.privateKey), + }, + fee_model: { + gas_limit: config.gasLimit, + max_fee_gwei: String(config.maxFeePerGasGwei), + max_priority_fee_gwei: String(config.maxPriorityFeePerGasGwei), + }, + }; +} + +function buildPreExecutionSettlementState(settlement, amount, config) { + return { + ...settlement, + transaction_hash: settlement?.transaction_hash ?? null, + block_number: settlement?.block_number ?? null, + block_timestamp: settlement?.block_timestamp ?? null, + confirmation_depth: settlement?.confirmation_depth ?? 0, + required_confirmation_depth: + settlement?.required_confirmation_depth ?? config.requiredConfirmations, + finality_status: settlement?.finality_status ?? 'PENDING', + actual_amount_transferred: settlement?.actual_amount_transferred ?? amount ?? '0', + actual_gas_cost_native: settlement?.actual_gas_cost_native ?? '0.000000', + actual_gas_cost_fiat: settlement?.actual_gas_cost_fiat ?? { + amount: '0.00', + currency: 'USD', + }, + actual_slippage_rate: settlement?.actual_slippage_rate ?? '0.0000', + adapter_execution_mode: + settlement?.adapter_execution_mode ?? + (config.broadcastEnabled ? 'LIVE_BROADCAST' : 'READ_ONLY'), + }; +} + +function buildProvider(config) { + if (config.provider) { + return config.provider; + } + return hasText(config.rpcUrl) ? new JsonRpcProvider(config.rpcUrl) : null; +} + +function buildWallet(config, provider) { + if (typeof config.walletFactory === 'function') { + return config.walletFactory({ + provider, + privateKey: config.privateKey, + sourceAddress: config.sourceAddress, + config, + }); + } + + return new Wallet(config.privateKey, provider); +} + +function buildUsdcContract(config, wallet) { + if (typeof config.contractFactory === 'function') { + return config.contractFactory({ + contractAddress: config.usdcContractAddress, + wallet, + config, + }); + } + + return new Contract(config.usdcContractAddress, ERC20_ABI, wallet); +} + +function parseTransferLog(log) { + if (!log || typeof log !== 'object') { + return null; + } + + if ( + log.eventName === 'Transfer' && + log.args && + log.args.from !== undefined && + log.args.to !== undefined && + log.args.value !== undefined + ) { + return { + from: log.args.from, + to: log.args.to, + value: safeBigInt(log.args.value), + }; + } + + if ( + log.fragment?.name === 'Transfer' && + log.args && + log.args.from !== undefined && + log.args.to !== undefined && + log.args.value !== undefined + ) { + return { + from: log.args.from, + to: log.args.to, + value: safeBigInt(log.args.value), + }; + } + + try { + const parsed = ERC20_INTERFACE.parseLog({ + topics: log.topics, + data: log.data, + }); + if (parsed?.name !== 'Transfer') { + return null; + } + + return { + from: parsed.args.from, + to: parsed.args.to, + value: safeBigInt(parsed.args.value), + }; + } catch { + return null; + } +} + +function buildExpectedTransfer({ config, record, settlement }) { + const expectedFrom = + normalizeComparisonAddress(getDebtorAddress(record)) ?? + normalizeComparisonAddress(settlement?.broadcast_transaction?.from) ?? + normalizeComparisonAddress(config.sourceAddress); + const expectedTo = normalizeComparisonAddress(getRecipientAddress(record)); + const expectedToken = normalizeComparisonAddress(config.usdcContractAddress); + const expectedValue = safeBigInt( + parseUnits( + record.interbank_settlement_amount?.amount ?? '0', + config.usdcDecimals, + ), + ); + + return { + expectedFrom, + expectedTo, + expectedToken, + expectedValue, + }; +} + +function verifyTransferLog({ config, record, receipt, settlement }) { + const expected = buildExpectedTransfer({ config, record, settlement }); + if ( + !expected.expectedFrom || + !expected.expectedTo || + !expected.expectedToken || + expected.expectedValue === null + ) { + return { + verified: false, + failure_reason: + 'Sepolia finality requires source wallet, recipient wallet, token contract, and amount evidence.', + }; + } + + const logs = Array.isArray(receipt?.logs) ? receipt.logs : []; + for (const log of logs) { + if (normalizeLogAddress(log.address) !== expected.expectedToken) { + continue; + } + + const transfer = parseTransferLog(log); + if (!transfer) { + continue; + } + + const from = normalizeComparisonAddress(transfer.from); + const to = normalizeComparisonAddress(transfer.to); + const value = transfer.value; + + if ( + from === expected.expectedFrom && + to === expected.expectedTo && + value === expected.expectedValue + ) { + return { + verified: true, + token_contract_address: normalizeHex(config.usdcContractAddress), + from: normalizeHex(transfer.from), + to: normalizeHex(transfer.to), + amount: formatUnits(value, config.usdcDecimals), + amount_raw: value.toString(), + log_index: log.index ?? log.logIndex ?? null, + }; + } + } + + return { + verified: false, + token_contract_address: normalizeHex(config.usdcContractAddress), + expected_from: normalizeHex(expected.expectedFrom), + expected_to: normalizeHex(expected.expectedTo), + expected_amount_raw: expected.expectedValue.toString(), + failure_reason: + 'Sepolia transaction receipt did not contain the expected USDC Transfer log.', + }; +} + +async function getProviderChainId(provider) { + if (!provider || typeof provider.getNetwork !== 'function') { + return null; + } + + const network = await provider.getNetwork(); + if (!network || network.chainId === undefined || network.chainId === null) { + return null; + } + + try { + return BigInt(network.chainId); + } catch { + return null; + } +} + +async function validateSepoliaNetwork(provider) { + const chainId = await getProviderChainId(provider); + if (chainId === null) { + return null; + } + + if (chainId !== SEPOLIA_CHAIN_ID) { + return `Configured RPC endpoint is on chain_id ${chainId}, expected Sepolia ${SEPOLIA_CHAIN_ID}.`; + } + + return null; +} + +async function buildFeeEstimate(config, provider) { + if (!provider) { + return buildFallbackFeeEstimate(config); + } + + try { + const feeData = await provider.getFeeData(); + const maxFeePerGas = + feeData.maxFeePerGas ?? parseGweiToWei(config.maxFeePerGasGwei); + const gasCostWei = maxFeePerGas * BigInt(config.gasLimit); + return { + ...buildFallbackFeeEstimate(config), + gas_cost_native: { + amount: formatEther(gasCostWei), + currency: 'ETH', + }, + }; + } catch { + return buildFallbackFeeEstimate(config); + } +} + +async function getReceiptSettlementState({ config, provider, record, settlement }) { + if (!provider || !settlement?.transaction_hash) { + return settlement; + } + + const networkFailure = await validateSepoliaNetwork(provider); + if (networkFailure) { + return { + ...settlement, + finality_status: 'FAILED', + network_failure_reason: networkFailure, + }; + } + + const receipt = await provider.getTransactionReceipt(settlement.transaction_hash); + if (!receipt) { + return { + ...settlement, + finality_status: 'PENDING', + confirmation_depth: 0, + }; + } + + const latestBlock = await provider.getBlockNumber(); + const confirmationDepth = Math.max(latestBlock - receipt.blockNumber + 1, 0); + const finalityStatus = + confirmationDepth >= config.requiredConfirmations ? 'FINAL' : 'PROBABILISTIC'; + const block = await provider.getBlock(receipt.blockNumber); + const gasCostWei = + receipt.gasUsed * (receipt.gasPrice ?? receipt.effectiveGasPrice ?? 0n); + const transferVerification = + receipt.status === 0 + ? { + verified: false, + failure_reason: 'Sepolia transaction reverted before token transfer verification.', + } + : verifyTransferLog({ config, record, receipt, settlement }); + const finalityFailureReason = transferVerification.verified + ? null + : transferVerification.failure_reason; + + return { + ...settlement, + block_number: receipt.blockNumber, + block_timestamp: block?.timestamp + ? new Date(block.timestamp * 1000).toISOString() + : settlement.block_timestamp ?? nowIso(), + confirmation_depth: confirmationDepth, + required_confirmation_depth: config.requiredConfirmations, + finality_status: + receipt.status === 0 || !transferVerification.verified + ? 'FAILED' + : finalityStatus, + transfer_verification: transferVerification, + network_failure_reason: + receipt.status === 0 + ? 'Sepolia transaction reverted.' + : finalityFailureReason ?? settlement.network_failure_reason ?? null, + actual_amount_transferred: + transferVerification.verified + ? transferVerification.amount + : (settlement.actual_amount_transferred ?? + record.interbank_settlement_amount?.amount ?? + '0'), + actual_gas_cost_native: formatEther(gasCostWei), + adapter_execution_mode: config.broadcastEnabled ? 'LIVE_BROADCAST' : 'READ_ONLY', + }; +} + +async function broadcastUsdcTransfer({ config, provider, record, settlement }) { + if (!provider || !hasText(config.rpcUrl)) { + return { + status: 'FAILED', + failureReason: 'Sepolia broadcast is enabled but no RPC URL is configured.', + onChainSettlement: settlement, + }; + } + + const networkFailure = await validateSepoliaNetwork(provider); + if (networkFailure) { + return { + status: 'FAILED', + failureReason: networkFailure, + onChainSettlement: settlement, + }; + } + + if (!hasText(config.privateKey)) { + return { + status: 'FAILED', + failureReason: 'Sepolia broadcast is enabled but no private key is configured.', + onChainSettlement: settlement, + }; + } + + if (!hasText(config.usdcContractAddress)) { + return { + status: 'FAILED', + failureReason: 'Sepolia broadcast is enabled but no USDC contract address is configured.', + onChainSettlement: settlement, + }; + } + + const recipientAddress = getRecipientAddress(record); + if (!recipientAddress) { + return { + status: 'FAILED', + failureReason: + 'Sepolia broadcast requires creditor_account.proxy.identification as recipient wallet address.', + onChainSettlement: settlement, + }; + } + + const wallet = buildWallet(config, provider); + const sourceAddress = config.sourceAddress ?? wallet.address; + if (sourceAddress.toLowerCase() !== wallet.address.toLowerCase()) { + return { + status: 'FAILED', + failureReason: 'Configured Sepolia source address does not match private key.', + onChainSettlement: settlement, + }; + } + + const corridorFailure = validateSepoliaUsdcCorridor({ + config, + record, + wallet, + }); + if (corridorFailure) { + return { + status: 'FAILED', + failureReason: corridorFailure, + onChainSettlement: settlement, + }; + } + + const contract = buildUsdcContract(config, wallet); + const amount = parseUnits( + record.interbank_settlement_amount?.amount ?? '0', + config.usdcDecimals, + ); + const transaction = await contract.transfer(recipientAddress, amount, { + gasLimit: BigInt(config.gasLimit), + maxFeePerGas: parseGweiToWei(config.maxFeePerGasGwei), + maxPriorityFeePerGas: parseGweiToWei(config.maxPriorityFeePerGasGwei), + }); + + return { + status: 'BROADCAST', + failureReason: null, + onChainSettlement: { + ...settlement, + transaction_hash: transaction.hash, + confirmation_depth: 0, + required_confirmation_depth: config.requiredConfirmations, + finality_status: 'PENDING', + adapter_execution_mode: 'LIVE_BROADCAST', + broadcast_transaction: { + from: wallet.address, + to: config.usdcContractAddress, + recipient_wallet_address: recipientAddress, + }, + }, + }; +} + +export function createSepoliaUsdcAdapter(config = {}) { + const normalizedConfig = normalizeConfig(config); + const provider = buildProvider(normalizedConfig); + + return { + id: 'sepolia-usdc', + mode: normalizedConfig.broadcastEnabled ? 'TESTNET_BROADCAST' : 'TESTNET_READ_ONLY', + chain_family: 'EVM', + + hasExpired(expiryDateTime) { + return Boolean(expiryDateTime) && Date.parse(expiryDateTime) <= Date.now(); + }, + + buildFeeEstimate() { + return buildFallbackFeeEstimate(normalizedConfig); + }, + + describeLifecycle(input = {}) { + return buildAdapterMetadata(normalizedConfig, input); + }, + + async buildQuoteResponse(request = {}) { + const createdAt = nowIso(); + return { + quote_id: randomUUID(), + valid_until: new Date(Date.now() + 5 * 60 * 1000).toISOString(), + fee_lock_type: 'INDICATIVE', + fee_estimate: await buildFeeEstimate(normalizedConfig, provider), + estimated_confirmation_seconds: normalizedConfig.requiredConfirmations * 12, + chain_conditions: { + congestion_level: provider ? 'RPC_AVAILABLE' : 'RPC_NOT_CONFIGURED', + current_base_fee_gwei: null, + average_block_time_seconds: 12, + }, + adapter_metadata: this.describeLifecycle(request), + created_at: createdAt, + }; + }, + + normalizeOnChainSettlement(onChainSettlement, amount) { + return buildPreExecutionSettlementState( + onChainSettlement, + amount, + normalizedConfig, + ); + }, + + getLifecycleTimestamp(record, status) { + const history = record.status_history ?? []; + for (let index = history.length - 1; index >= 0; index -= 1) { + if (history[index].status === status) { + return history[index].status_at; + } + } + + return record.updated_at ?? record.created_at ?? nowIso(); + }, + + async deriveLifecycleState(record) { + const normalizedSettlement = this.normalizeOnChainSettlement( + record.on_chain_settlement, + record.interbank_settlement_amount?.amount, + record, + ); + + if (record.status === 'PENDING' && this.hasExpired(record.expiry_date_time)) { + return { + status: 'EXPIRED', + failureReason: 'Instruction expired before execution.', + onChainSettlement: normalizedSettlement, + }; + } + + if (['CANCELLED', 'EXPIRED', 'SLIPPAGE_EXCEEDED', 'RAMP_FAILED', 'FAILED'].includes(record.status)) { + return { + status: record.status, + failureReason: record.failure_reason ?? null, + onChainSettlement: normalizedSettlement, + }; + } + + if (normalizedSettlement.transaction_hash) { + const settlement = await getReceiptSettlementState({ + config: normalizedConfig, + provider, + record, + settlement: normalizedSettlement, + }); + const status = + settlement.finality_status === 'FAILED' + ? 'FAILED' + : settlement.finality_status === 'FINAL' + ? 'FINAL' + : settlement.block_number + ? 'CONFIRMING' + : 'BROADCAST'; + + return { + status, + failureReason: + settlement.finality_status === 'FAILED' + ? settlement.network_failure_reason ?? 'Sepolia transaction reverted.' + : null, + onChainSettlement: settlement, + }; + } + + return { + status: record.status, + failureReason: record.failure_reason ?? null, + onChainSettlement: normalizedSettlement, + }; + }, + + async submitLifecycleState(record) { + const normalizedSettlement = this.normalizeOnChainSettlement( + record.on_chain_settlement, + record.interbank_settlement_amount?.amount, + record, + ); + + if (record.status === 'PENDING' && this.hasExpired(record.expiry_date_time)) { + return { + status: 'EXPIRED', + failureReason: 'Instruction expired before execution.', + onChainSettlement: normalizedSettlement, + }; + } + + if ( + !normalizedConfig.broadcastEnabled || + normalizedSettlement.transaction_hash || + !['PENDING', 'BROADCAST', 'CONFIRMING'].includes(record.status) + ) { + return this.deriveLifecycleState(record); + } + + try { + return await broadcastUsdcTransfer({ + config: normalizedConfig, + provider, + record, + settlement: normalizedSettlement, + }); + } catch (error) { + return { + status: 'FAILED', + failureReason: `Sepolia broadcast failed: ${error.message}`, + onChainSettlement: normalizedSettlement, + }; + } + }, + }; +} diff --git a/reference-server/src/config.js b/reference-server/src/config.js new file mode 100644 index 0000000..470be3e --- /dev/null +++ b/reference-server/src/config.js @@ -0,0 +1,91 @@ +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const moduleDir = dirname(fileURLToPath(import.meta.url)); +const projectRoot = resolve(moduleDir, '..'); +const DEFAULT_WEBHOOK_RETRY_SCHEDULE_MS = [30_000, 120_000, 600_000, 1_800_000]; + +function parsePositiveInteger(value, fallback) { + const parsed = Number.parseInt(value ?? '', 10); + return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; +} + +function parseBoolean(value, fallback) { + if (value === undefined) { + return fallback; + } + + const normalized = String(value).trim().toLowerCase(); + if (['1', 'true', 'yes', 'on'].includes(normalized)) { + return true; + } + if (['0', 'false', 'no', 'off'].includes(normalized)) { + return false; + } + + return fallback; +} + +function parseRetryScheduleMs(value) { + if (!value) { + return [...DEFAULT_WEBHOOK_RETRY_SCHEDULE_MS]; + } + + const schedule = String(value) + .split(',') + .map((item) => Number.parseInt(item.trim(), 10)) + .filter((item) => Number.isInteger(item) && item > 0); + + return schedule.length > 0 ? schedule : [...DEFAULT_WEBHOOK_RETRY_SCHEDULE_MS]; +} + +export function loadConfig() { + const chainAdapter = process.env.REF_SERVER_CHAIN_ADAPTER ?? 'mock-evm'; + return { + host: process.env.REF_SERVER_HOST ?? '127.0.0.1', + port: Number(process.env.REF_SERVER_PORT ?? 5050), + dbPath: + process.env.REF_SERVER_DB_PATH ?? + resolve(projectRoot, 'data', 'reference-stack.sqlite'), + webhookDispatch: { + enabled: parseBoolean(process.env.REF_SERVER_WEBHOOK_AUTO_DISPATCH, true), + intervalMs: parsePositiveInteger( + process.env.REF_SERVER_WEBHOOK_DISPATCH_INTERVAL_MS, + 1000, + ), + batchSize: parsePositiveInteger( + process.env.REF_SERVER_WEBHOOK_DISPATCH_BATCH_SIZE, + 20, + ), + }, + webhookRetryScheduleMs: parseRetryScheduleMs( + process.env.REF_SERVER_WEBHOOK_RETRY_SCHEDULE_MS, + ), + chainAdapter: { + id: chainAdapter, + sepolia: { + rpcUrl: process.env.REF_SERVER_SEPOLIA_RPC_URL ?? null, + privateKey: process.env.REF_SERVER_SEPOLIA_PRIVATE_KEY ?? null, + usdcContractAddress: + process.env.REF_SERVER_SEPOLIA_USDC_CONTRACT_ADDRESS ?? null, + sourceAddress: process.env.REF_SERVER_SEPOLIA_SOURCE_ADDRESS ?? null, + requiredConfirmations: parsePositiveInteger( + process.env.REF_SERVER_SEPOLIA_REQUIRED_CONFIRMATIONS, + 3, + ), + gasLimit: parsePositiveInteger( + process.env.REF_SERVER_SEPOLIA_GAS_LIMIT, + 85000, + ), + broadcastEnabled: parseBoolean( + process.env.REF_SERVER_SEPOLIA_BROADCAST_ENABLED, + false, + ), + maxFeePerGasGwei: + process.env.REF_SERVER_SEPOLIA_MAX_FEE_GWEI ?? '35', + maxPriorityFeePerGasGwei: + process.env.REF_SERVER_SEPOLIA_MAX_PRIORITY_FEE_GWEI ?? '2', + }, + }, + }; +} diff --git a/reference-server/src/db.js b/reference-server/src/db.js new file mode 100644 index 0000000..5546b81 --- /dev/null +++ b/reference-server/src/db.js @@ -0,0 +1,5699 @@ +import { mkdirSync } from 'node:fs'; +import { dirname } from 'node:path'; +import { createHash, createHmac, randomUUID } from 'node:crypto'; +import { DatabaseSync } from 'node:sqlite'; + +import { normalizeChainAdapter } from './chain/adapter-contract.js'; + +const TRAVEL_RULE_CALLBACK_STATUSES = new Set([ + 'ACCEPTED', + 'REJECTED', + 'UNDER_REVIEW', +]); +const TRAVEL_RULE_SUBMISSION_TIMINGS = new Set([ + 'PRE_TX', + 'POST_TX', + 'SIMULTANEOUS', +]); +const WEBHOOK_EVENT_TYPES = [ + 'execution_status.updated', + 'finality_receipt.updated', + 'reporting_notification.created', + 'reporting_statement.ready', + 'investigation_case.updated', + 'return_case.updated', +]; +const WEBHOOK_DELIVERY_GUARANTEE = 'AT_LEAST_ONCE_BEST_EFFORT'; +const WEBHOOK_DELIVERY_TERMINAL_STATES = new Set(['DELIVERED', 'FAILED']); + +function nowIso() { + return new Date().toISOString(); +} + +function parseJson(value, fallback) { + if (!value) { + return fallback; + } + + try { + return JSON.parse(value); + } catch { + return fallback; + } +} + +function serialize(value) { + return JSON.stringify(value); +} + +function isPromiseLike(value) { + return value !== null && typeof value === 'object' && typeof value.then === 'function'; +} + +function formatDecimalAmount(value, digits = 8) { + return Number(value).toFixed(digits).replace(/\.?0+$/, ''); +} + +function buildWebhookSignature(signingSecret, timestamp, body) { + const digest = createHmac('sha256', signingSecret) + .update(`${timestamp}.${body}`) + .digest('hex'); + return `t=${timestamp},v1=${digest}`; +} + +const DEFAULT_WEBHOOK_RETRY_SCHEDULE_MS = [30_000, 120_000, 600_000, 1_800_000]; + +function normalizeRetryScheduleMs(retryScheduleMs) { + if (!Array.isArray(retryScheduleMs)) { + return [...DEFAULT_WEBHOOK_RETRY_SCHEDULE_MS]; + } + + const normalized = retryScheduleMs + .map((value) => Number.parseInt(value, 10)) + .filter((value) => Number.isInteger(value) && value > 0); + + return normalized.length > 0 + ? normalized + : [...DEFAULT_WEBHOOK_RETRY_SCHEDULE_MS]; +} + +function buildNextAttemptAt(fromIso, attemptCount, retryScheduleMs) { + const schedule = normalizeRetryScheduleMs(retryScheduleMs); + const delayMs = schedule[Math.min(attemptCount - 1, schedule.length - 1)]; + return new Date( + Date.parse(fromIso) + delayMs, + ).toISOString(); +} + +function normalizeTravelRuleSubmissionTiming(value) { + return TRAVEL_RULE_SUBMISSION_TIMINGS.has(value) ? value : 'PRE_TX'; +} + +function parseListFilter(value) { + if (Array.isArray(value)) { + return value.flatMap((item) => String(item).split(',')).map((item) => item.trim()).filter(Boolean); + } + + if (typeof value === 'string') { + return value.split(',').map((item) => item.trim()).filter(Boolean); + } + + return []; +} + +function parseOptionalBoolean(value) { + if (value === undefined || value === null || value === '') { + return null; + } + + const normalized = String(value).trim().toLowerCase(); + if (['1', 'true', 'yes', 'on'].includes(normalized)) { + return true; + } + if (['0', 'false', 'no', 'off'].includes(normalized)) { + return false; + } + + return null; +} + +function encodeCursor(index) { + return Buffer.from(JSON.stringify({ index }), 'utf8').toString('base64'); +} + +function decodeCursor(cursor) { + if (!cursor) { + return 0; + } + + try { + const payload = JSON.parse(Buffer.from(cursor, 'base64').toString('utf8')); + return Number.isInteger(payload.index) && payload.index >= 0 ? payload.index : 0; + } catch { + return 0; + } +} + +function getTravelRuleData(record) { + return record.travel_rule_data ?? {}; +} + +function getTravelRuleLatestCallbackStatus(record) { + return record.callbacks?.at(-1)?.callback_status ?? 'PENDING'; +} + +function getTravelRulePrimaryChainId(record) { + return ( + getTravelRuleData(record).blockchain_settlement?.primary_chain_id ?? + getTravelRuleData(record).settlement_instruction?.clearing_system?.proprietary ?? + null + ); +} + +function getTravelRuleTokenIdentification(record) { + const data = getTravelRuleData(record); + return ( + data.token_identification ?? + data.blockchain_settlement?.legs?.[0]?.token_identification ?? + null + ); +} + +function getTravelRuleDirection() { + return 'OUTGOING'; +} + +function getTravelRuleVolumeKey(record) { + const data = getTravelRuleData(record); + const currency = data.interbank_settlement_amount?.currency ?? 'XXX'; + const tokenIdentification = getTravelRuleTokenIdentification(record); + return serialize({ + currency, + token_identification: currency === 'XXX' ? tokenIdentification : null, + }); +} + +function buildTravelRuleCurrencyVolumeMap(records) { + const volumes = new Map(); + for (const record of records) { + const data = getTravelRuleData(record); + const key = getTravelRuleVolumeKey(record); + const existing = volumes.get(key) ?? { + currency: data.interbank_settlement_amount?.currency ?? 'XXX', + token_identification: + (data.interbank_settlement_amount?.currency ?? 'XXX') === 'XXX' + ? getTravelRuleTokenIdentification(record) + : undefined, + total_amount: 0, + record_count: 0, + }; + + existing.total_amount += Number.parseFloat( + data.interbank_settlement_amount?.amount ?? '0', + ); + existing.record_count += 1; + volumes.set(key, existing); + } + + return Array.from(volumes.values()).map((entry) => ({ + currency: entry.currency, + ...(entry.token_identification ? { token_identification: entry.token_identification } : {}), + total_amount: entry.total_amount.toFixed(8).replace(/\.?0+$/, (match) => + match.includes('.') ? '' : match, + ), + record_count: entry.record_count, + })); +} + +function buildTravelRuleSummary(record) { + const data = getTravelRuleData(record); + const amount = data.interbank_settlement_amount ?? {}; + return { + record_id: record.record_id, + direction: getTravelRuleDirection(record), + submitted_at: record.submitted_at, + last_updated_at: record.last_updated_at, + status: record.status, + submission_timing: record.submission_timing, + primary_chain_id: getTravelRulePrimaryChainId(record), + is_bridge: (data.blockchain_settlement?.legs?.length ?? 0) > 1, + debtor_name: data.debtor?.name ?? null, + debtor_vasp_lei: + data.debtor_agent?.financial_institution_identification?.lei ?? + data.debtor_agent?.lei ?? + null, + creditor_name: data.creditor?.name ?? null, + creditor_vasp_lei: + data.creditor_agent?.financial_institution_identification?.lei ?? + data.creditor_agent?.lei ?? + null, + settlement_amount: amount.amount ?? null, + settlement_currency: amount.currency ?? null, + token_identification: getTravelRuleTokenIdentification(record), + debtor_wallet_type: data.debtor_wallet_type ?? null, + creditor_wallet_type: data.creditor_wallet_type ?? null, + latest_callback_status: getTravelRuleLatestCallbackStatus(record), + }; +} + +function buildTravelRuleCallbackReceipt(record, previousStatus, callbackRecordedAt) { + return { + record_id: record.record_id, + callback_recorded_at: callbackRecordedAt, + current_status: record.status, + previous_status: previousStatus ?? null, + }; +} + +function getTravelRuleBreakdownKey(record, groupBy) { + const data = getTravelRuleData(record); + const summary = buildTravelRuleSummary(record); + + if (groupBy === 'status') { + return { value: record.status, label: record.status }; + } + if (groupBy === 'callback_status') { + return { + value: getTravelRuleLatestCallbackStatus(record), + label: getTravelRuleLatestCallbackStatus(record), + }; + } + if (groupBy === 'submission_timing') { + return { value: record.submission_timing, label: record.submission_timing }; + } + if (groupBy === 'chain') { + return { + value: summary.primary_chain_id ?? 'UNKNOWN', + label: summary.primary_chain_id ?? 'UNKNOWN', + }; + } + if (groupBy === 'token') { + const token = summary.token_identification ?? {}; + return { + value: token.dti ?? token.isin ?? token.ticker ?? 'UNKNOWN', + label: token.ticker ?? token.dti ?? token.isin ?? 'UNKNOWN', + }; + } + if (groupBy === 'wallet_type') { + const value = summary.debtor_wallet_type ?? summary.creditor_wallet_type ?? 'UNKNOWN'; + return { value, label: value }; + } + if (groupBy === 'counterparty_vasp') { + return { + value: summary.creditor_vasp_lei ?? 'UNKNOWN', + label: + data.creditor_agent?.financial_institution_identification?.name ?? + data.creditor_agent?.name ?? + summary.creditor_vasp_lei ?? + 'UNKNOWN', + }; + } + if (groupBy === 'rejection_reason_code') { + const code = record.callbacks?.at(-1)?.rejection_reasons?.[0]?.code ?? 'NONE'; + return { value: code, label: code }; + } + + return null; +} + +function buildAdapterMetadata(chainAdapter, input) { + if (typeof chainAdapter?.describeLifecycle !== 'function') { + return null; + } + + return chainAdapter.describeLifecycle(input); +} + +function buildInstructionResponse(record, chainAdapter = null) { + return { + instruction_id: record.instruction_id, + uetr: record.uetr, + status: record.status, + failure_reason: record.failure_reason ?? null, + custody_model: record.custody_model, + fee_estimate: record.fee_estimate, + expiry_date_time: record.expiry_date_time, + debit_timing: record.debit_timing, + adapter_metadata: buildAdapterMetadata(chainAdapter, record), + created_at: record.created_at, + }; +} + +function buildInstructionDetailResponse(record, chainAdapter = null) { + return { + ...record, + adapter_metadata: buildAdapterMetadata(chainAdapter, record), + }; +} + +function buildCancellationResponse(record, cancelledAt) { + return { + instruction_id: record.instruction_id, + status: 'CANCELLED', + cancelled_at: cancelledAt, + }; +} + +function getInstructionStatusReason(status, failureReason = null) { + if (status === 'PENDING') { + return { + code: 'ACCEPTED_FOR_EXECUTION', + description: 'Instruction accepted and queued for execution.', + }; + } + if (status === 'QUOTED') { + return { + code: 'AWAITING_SIGNATURE', + description: 'Unsigned transaction returned; awaiting delegated signing.', + }; + } + if (status === 'BROADCAST') { + return { + code: 'BROADCAST_TO_CHAIN', + description: 'Transaction submitted to the blockchain network.', + }; + } + if (status === 'CONFIRMING') { + return { + code: 'BLOCK_INCLUDED', + description: 'Transaction included in a block and accumulating confirmations.', + }; + } + if (status === 'FINAL') { + return { + code: 'FINALITY_THRESHOLD_REACHED', + description: 'Transaction reached the required finality threshold.', + }; + } + if (status === 'FAILED') { + return { + code: 'EXECUTION_FAILED', + description: failureReason ?? 'Instruction execution failed.', + }; + } + if (status === 'CANCELLED') { + return { + code: 'CANCELLED_BY_INSTRUCTING_PARTY', + description: 'Instruction cancelled before on-chain broadcast.', + }; + } + if (status === 'EXPIRED') { + return { + code: 'EXPIRY_REACHED', + description: failureReason ?? 'Instruction expired before execution.', + }; + } + if (status === 'SLIPPAGE_EXCEEDED') { + return { + code: 'SLIPPAGE_LIMIT_EXCEEDED', + description: failureReason ?? 'Instruction rejected because slippage exceeded the limit.', + }; + } + if (status === 'RAMP_FAILED') { + return { + code: 'RAMP_EXECUTION_FAILED', + description: failureReason ?? 'On/off-ramp execution failed.', + }; + } + + return { + code: 'STATUS_UPDATED', + description: 'Instruction status updated.', + }; +} + +function buildInstructionStatusEvent({ status, statusAt, failureReason = null }) { + const reason = getInstructionStatusReason(status, failureReason); + return { + event_id: randomUUID(), + status, + status_at: statusAt, + reason_code: reason.code, + description: reason.description, + }; +} + +function buildInstructionStatusHistory(record) { + return (record.status_history ?? []).map((event, index) => ({ + sequence: index + 1, + ...event, + })); +} + +function deriveInstructionStatusGroup(status) { + if (['PENDING', 'QUOTED'].includes(status)) { + return 'PRE_EXECUTION'; + } + if (['BROADCAST', 'CONFIRMING'].includes(status)) { + return 'IN_FLIGHT'; + } + if (status === 'FINAL') { + return 'SETTLED'; + } + if (status === 'CANCELLED') { + return 'CANCELLED'; + } + + return 'EXCEPTION'; +} + +function findInstructionStatusEvent(record, status) { + const history = record.status_history ?? []; + for (let index = history.length - 1; index >= 0; index -= 1) { + if (history[index].status === status) { + return history[index]; + } + } + + return null; +} + +function buildExecutionStatusResponse(record, chainAdapter = null) { + const history = buildInstructionStatusHistory(record); + const latestStatusEvent = history.at(-1) ?? null; + + return { + instruction_id: record.instruction_id, + uetr: record.uetr, + end_to_end_identification: + record.payment_identification?.end_to_end_identification ?? null, + travel_rule_record_id: record.travel_rule_record_id, + status: record.status, + status_group: deriveInstructionStatusGroup(record.status), + latest_status_at: latestStatusEvent?.status_at ?? record.updated_at, + failure_reason: record.failure_reason, + chain_dli: record.blockchain_instruction?.chain_dli ?? null, + token: record.blockchain_instruction?.token ?? null, + transaction_hash: record.on_chain_settlement?.transaction_hash ?? null, + finality_status: record.on_chain_settlement?.finality_status ?? null, + confirmation_depth: record.on_chain_settlement?.confirmation_depth ?? null, + required_confirmation_depth: + record.on_chain_settlement?.required_confirmation_depth ?? null, + transfer_verification: + record.on_chain_settlement?.transfer_verification ?? null, + debit_timing: record.debit_timing, + expiry_date_time: record.expiry_date_time, + created_at: record.created_at, + updated_at: record.updated_at, + adapter_metadata: buildAdapterMetadata(chainAdapter, record), + status_history: history, + }; +} + +function buildFinalityReceipt(record, chainAdapter = null) { + const broadcastEvent = findInstructionStatusEvent(record, 'BROADCAST'); + const confirmingEvent = findInstructionStatusEvent(record, 'CONFIRMING'); + const finalEvent = findInstructionStatusEvent(record, 'FINAL'); + let notApplicableReason = null; + + if (record.status === 'CANCELLED') { + notApplicableReason = 'Instruction was cancelled before on-chain broadcast.'; + } else if (record.status === 'EXPIRED') { + notApplicableReason = 'Instruction expired before on-chain broadcast.'; + } else if (['PENDING', 'QUOTED'].includes(record.status)) { + notApplicableReason = 'Instruction has not yet been broadcast to chain.'; + } + + return { + instruction_id: record.instruction_id, + uetr: record.uetr, + end_to_end_identification: + record.payment_identification?.end_to_end_identification ?? null, + travel_rule_record_id: record.travel_rule_record_id, + instruction_status: record.status, + chain_dli: record.blockchain_instruction?.chain_dli ?? null, + token: record.blockchain_instruction?.token ?? null, + settlement_amount: record.interbank_settlement_amount ?? null, + transaction_hash: record.on_chain_settlement?.transaction_hash ?? null, + broadcast_at: broadcastEvent?.status_at ?? null, + included_at: + confirmingEvent?.status_at ?? + record.on_chain_settlement?.block_timestamp ?? + null, + block_number: record.on_chain_settlement?.block_number ?? null, + block_timestamp: record.on_chain_settlement?.block_timestamp ?? null, + confirmation_depth: record.on_chain_settlement?.confirmation_depth ?? null, + required_confirmation_depth: + record.on_chain_settlement?.required_confirmation_depth ?? null, + finality_status: record.on_chain_settlement?.finality_status ?? null, + transfer_verification: + record.on_chain_settlement?.transfer_verification ?? null, + observed_at: record.updated_at, + final_at: finalEvent?.status_at ?? null, + not_applicable_reason: notApplicableReason, + adapter_metadata: buildAdapterMetadata(chainAdapter, record), + }; +} + +function normalizeInstructionStatusHistory(statusHistory, record) { + if (Array.isArray(statusHistory) && statusHistory.length > 0) { + return statusHistory; + } + + return [ + buildInstructionStatusEvent({ + status: record.status, + statusAt: record.updated_at ?? record.created_at ?? nowIso(), + failureReason: record.failure_reason ?? null, + }), + ]; +} + +function appendInstructionStatusEvent(record, status, statusAt, failureReason = null) { + const history = normalizeInstructionStatusHistory(record.status_history, record); + if (history.at(-1)?.status === status) { + return history; + } + + return [ + ...history, + buildInstructionStatusEvent({ + status, + statusAt, + failureReason, + }), + ]; +} + +function buildInstructionLifecycleUpdate(record, lifecycleState) { + const status = lifecycleState.status; + const failureReason = lifecycleState.failureReason; + const updatedAt = + status === record.status ? record.updated_at : new Date().toISOString(); + + return { + ...record, + status, + updated_at: updatedAt, + failure_reason: failureReason, + status_history: + status === record.status + ? record.status_history + : appendInstructionStatusEvent( + record, + status, + updatedAt, + failureReason, + ), + on_chain_settlement: { + ...lifecycleState.onChainSettlement, + }, + }; +} + +function normalizeTravelRuleSubmission(submission) { + const travelRuleData = { + ...(submission.travel_rule_data ?? {}), + }; + if (!travelRuleData.blockchain_settlement && submission.blockchain_settlement) { + travelRuleData.blockchain_settlement = submission.blockchain_settlement; + } + + return { + submission_timing: normalizeTravelRuleSubmissionTiming(submission.submission_timing), + travel_rule_data: travelRuleData, + submitting_vasp: + submission.submitting_vasp ?? + submission.travel_rule_data?.debtor_agent ?? + null, + correction_of_callback_ref: submission.correction_of_callback_ref ?? null, + }; +} + +function normalizeInstructionSubmission(submission) { + const paymentIdentification = submission.payment_identification ?? {}; + const blockchainInstruction = submission.blockchain_instruction ?? {}; + const token = blockchainInstruction.token ?? submission.token ?? {}; + + return { + payment_identification: { + ...paymentIdentification, + end_to_end_identification: + paymentIdentification.end_to_end_identification ?? + `E2E-${randomUUID().slice(0, 8).toUpperCase()}`, + uetr: paymentIdentification.uetr ?? randomUUID(), + quote_id: paymentIdentification.quote_id ?? null, + }, + settlement_information: submission.settlement_information ?? null, + payment_type_information: submission.payment_type_information ?? null, + debtor: submission.debtor ?? null, + debtor_account: submission.debtor_account ?? null, + debtor_agent: submission.debtor_agent ?? null, + creditor: submission.creditor ?? null, + creditor_account: submission.creditor_account ?? null, + creditor_agent: submission.creditor_agent ?? null, + charge_bearer: submission.charge_bearer ?? null, + interbank_settlement_amount: + submission.interbank_settlement_amount ?? { + amount: submission.amount ?? '0', + currency: submission.currency ?? 'USD', + }, + instructed_amount: submission.instructed_amount ?? null, + instruction_for_next_agent: submission.instruction_for_next_agent ?? null, + purpose: submission.purpose ?? null, + remittance_information: submission.remittance_information ?? null, + blockchain_instruction: { + token: { + token_symbol: token.token_symbol ?? submission.currency ?? 'USDC', + token_dti: token.token_dti ?? 'T9B3X8H2K', + ...token, + }, + chain_dli: blockchainInstruction.chain_dli ?? submission.chain_dli ?? 'X9J9XDMTD', + custody_model: blockchainInstruction.custody_model ?? submission.custody_model ?? 'FULL_CUSTODY', + maximum_slippage_rate: + blockchainInstruction.maximum_slippage_rate ?? + submission.maximum_slippage_rate ?? + '0.0010', + ...blockchainInstruction, + }, + travel_rule_record_id: submission.travel_rule_record_id ?? null, + expiry_date_time: + submission.expiry_date_time ?? + new Date(Date.now() + 30 * 60 * 1000).toISOString(), + }; +} + +function buildInstructionSearchSummary(record) { + return { + instruction_id: record.instruction_id, + status: record.status, + end_to_end_identification: + record.payment_identification?.end_to_end_identification ?? null, + debtor_name: record.debtor?.name ?? null, + creditor_name: record.creditor?.name ?? null, + created_at: record.created_at, + amount: record.interbank_settlement_amount?.amount ?? null, + currency: record.interbank_settlement_amount?.currency ?? null, + chain_dli: record.blockchain_instruction?.chain_dli ?? null, + token_symbol: record.blockchain_instruction?.token?.token_symbol ?? null, + finality_status: record.on_chain_settlement?.finality_status ?? null, + transaction_hash: record.on_chain_settlement?.transaction_hash ?? null, + }; +} + +function getInstructionStatusRank(status) { + if (['PENDING', 'QUOTED'].includes(status)) { + return 1; + } + if (status === 'BROADCAST') { + return 2; + } + if (status === 'CONFIRMING') { + return 3; + } + if (status === 'FINAL') { + return 4; + } + + return 0; +} + +function hasInstructionReachedStatus(record, status) { + return getInstructionStatusRank(record?.status) >= getInstructionStatusRank(status); +} + +function getDebitNotificationTriggerStatus(debitTiming) { + if (debitTiming === 'ON_ACCEPTANCE') { + return 'PENDING'; + } + if (debitTiming === 'ON_FINALITY') { + return 'FINAL'; + } + + return 'BROADCAST'; +} + +function toReportEntryStatus(bookingStatus) { + return bookingStatus === 'BOOKED' ? 'BOOK' : 'PDNG'; +} + +function toReportCreditDebitIndicator(entryType) { + return entryType === 'DEBIT' ? 'DBIT' : 'CRDT'; +} + +function compareReportingRecords(left, right, sort = 'booking_date_desc') { + const leftAmount = Number.parseFloat(left.settlement_amount?.amount ?? '0'); + const rightAmount = Number.parseFloat(right.settlement_amount?.amount ?? '0'); + const leftBooking = Date.parse(left.booking_date_time ?? left.created_at ?? nowIso()); + const rightBooking = Date.parse(right.booking_date_time ?? right.created_at ?? nowIso()); + + switch (sort) { + case 'booking_date_asc': + return leftBooking - rightBooking; + case 'amount_asc': + return leftAmount - rightAmount || rightBooking - leftBooking; + case 'amount_desc': + return rightAmount - leftAmount || rightBooking - leftBooking; + case 'booking_date_desc': + default: + return rightBooking - leftBooking; + } +} + +function sortReportingNotificationRecords(records, sort) { + return [...records].sort((left, right) => compareReportingRecords(left, right, sort)); +} + +function buildStableReportingIdentifier(prefix, seed) { + const digest = createHash('sha256') + .update(String(seed ?? '')) + .digest('hex') + .slice(0, 12) + .toUpperCase(); + return `${prefix}-${digest}`.slice(0, 35); +} + +function toUtcDayStart(dateValue) { + return `${dateValue}T00:00:00Z`; +} + +function toUtcDayEnd(dateValue) { + return `${dateValue}T23:59:59.999Z`; +} + +function formatTokenAmountValue(value) { + return formatDecimalAmount(value, 8); +} + +function buildReportingBalance(amount, currency) { + return { + amount: formatDecimalAmount(Math.abs(amount)), + currency, + credit_debit_indicator: amount < 0 ? 'DBIT' : 'CRDT', + }; +} + +function buildSpecPartyIdentification(party = null) { + if (!party || typeof party !== 'object') { + return {}; + } + + return { + ...(party.name ? { name: party.name } : {}), + ...(party.lei ? { lei: party.lei } : {}), + ...(party.country ? { country: party.country } : {}), + ...(party.postal_address ? { postal_address: party.postal_address } : {}), + }; +} + +function buildSpecAgentIdentification(agent = null) { + if (!agent || typeof agent !== 'object') { + return null; + } + + const normalized = { + ...(agent.name ? { name: agent.name } : {}), + ...(agent.lei ? { lei: agent.lei } : {}), + ...(agent.bic ? { bic: agent.bic } : {}), + ...(agent.country ? { country: agent.country } : {}), + }; + + return Object.keys(normalized).length > 0 ? normalized : null; +} + +function buildSpecTokenIdentification(record, instruction = null) { + const token = + record?.token ?? + instruction?.blockchain_instruction?.token ?? + {}; + const chainDli = + token.chain_dli ?? + record?.chain_dli ?? + instruction?.blockchain_instruction?.chain_dli ?? + null; + + return { + ...(token.token_dti ? { token_dti: token.token_dti } : {}), + ...(token.contract_address ? { contract_address: token.contract_address } : {}), + ...(chainDli ? { chain_dli: chainDli } : {}), + ...(token.isin ? { isin: token.isin } : {}), + ...(token.token_symbol ? { token_symbol: token.token_symbol } : {}), + ...(token.token_standard ? { token_standard: token.token_standard } : {}), + ...(token.token_issuer ? { token_issuer: token.token_issuer } : {}), + }; +} + +function deriveReportingEntryLifecycle(record) { + const currentInstructionStatus = + record?.status_reference?.current_instruction_status ?? null; + const triggerStatus = record?.status_reference?.trigger_status ?? null; + + if (currentInstructionStatus === 'FINAL' || triggerStatus === 'FINAL') { + return { + entryStatus: 'BOOK', + finalityStatus: 'FINAL', + notificationType: 'ENTRY_FINAL', + }; + } + + if (currentInstructionStatus === 'CONFIRMING') { + return { + entryStatus: 'PDNG', + finalityStatus: 'PROBABILISTIC', + notificationType: 'ENTRY_PENDING', + }; + } + + return { + entryStatus: 'PDNG', + finalityStatus: record?.transaction_hash ? 'PROBABILISTIC' : 'PENDING', + notificationType: 'ENTRY_PENDING', + }; +} + +function buildSpecEntryReference(record) { + return buildStableReportingIdentifier( + 'ENT', + `${record?.instruction_id ?? ''}:${record?.account_role ?? ''}`, + ); +} + +function buildSpecWalletAccount(record = null, overrides = {}) { + const walletAddress = + overrides.wallet_address ?? + record?.party?.wallet_address ?? + null; + const chainDli = + overrides.chain_dli ?? + record?.chain_dli ?? + null; + const owner = buildSpecPartyIdentification(record?.party ?? null); + const servicer = buildSpecAgentIdentification(record?.servicing_agent ?? null); + const account = { + identification: { + proxy: { + type: { + code: 'EWAL', + }, + identification: walletAddress, + }, + }, + type: { + proprietary: `DLID/${chainDli}`, + }, + }; + + if (record?.party?.name) { + account.name = record.party.name; + } + if (Object.keys(owner).length > 0) { + account.owner = owner; + } + if (servicer) { + account.servicer = servicer; + } + + return account; +} + +function buildSpecGroupHeader({ + prefix, + seed, + createdAt, + recipient = null, + additionalInformation = null, +}) { + return { + message_identification: buildStableReportingIdentifier(prefix, seed), + creation_date_time: createdAt ?? nowIso(), + message_recipient: buildSpecPartyIdentification(recipient), + ...(additionalInformation + ? { additional_information: additionalInformation } + : {}), + }; +} + +function buildSpecChargesInformation(record, instruction = null) { + const amount = + instruction?.on_chain_settlement?.actual_gas_cost_fiat?.amount ?? + instruction?.fee_estimate?.gas_cost_fiat?.amount ?? + null; + const currency = + instruction?.on_chain_settlement?.actual_gas_cost_fiat?.currency ?? + instruction?.fee_estimate?.gas_cost_fiat?.currency ?? + null; + const agent = buildSpecAgentIdentification(record?.servicing_agent ?? null); + + if (!amount || !currency) { + return null; + } + + return { + amount, + currency, + ...(instruction?.charge_bearer ? { charge_bearer: instruction.charge_bearer } : {}), + ...(agent ? { agent } : {}), + }; +} + +function buildSpecGasDetail(record, instruction = null, chainAdapter = null) { + if (!instruction) { + return null; + } + + const adapterMetadata = buildAdapterMetadata(chainAdapter, instruction); + const chainDli = + record?.chain_dli ?? + instruction?.blockchain_instruction?.chain_dli ?? + null; + const nativeCurrency = + adapterMetadata?.network_profile?.native_currency ?? 'ETH'; + const nativeFeeAmount = + instruction?.on_chain_settlement?.actual_gas_cost_native ?? + instruction?.fee_estimate?.gas_cost_native?.amount ?? + null; + const fiatEquivalent = + instruction?.on_chain_settlement?.actual_gas_cost_fiat ?? + instruction?.fee_estimate?.gas_cost_fiat ?? + null; + const gasLimit = adapterMetadata?.fee_model?.gas_limit ?? null; + const detail = {}; + + if (nativeFeeAmount) { + detail.native_fee = { + amount: nativeFeeAmount, + token: { + token_symbol: nativeCurrency, + ...(chainDli ? { chain_dli: chainDli } : {}), + }, + }; + } + + if (fiatEquivalent?.amount && fiatEquivalent?.currency) { + detail.fiat_equivalent = { + amount: fiatEquivalent.amount, + currency: fiatEquivalent.currency, + }; + } + + if (gasLimit !== null && gasLimit !== undefined) { + detail.gas_limit = gasLimit; + } + + return Object.keys(detail).length > 0 ? detail : null; +} + +function buildSpecBlockchainDetail(record, instruction = null, chainAdapter = null) { + const transactionHash = + record?.transaction_hash ?? + instruction?.on_chain_settlement?.transaction_hash ?? + null; + const chainDli = + record?.chain_dli ?? + instruction?.blockchain_instruction?.chain_dli ?? + null; + + if (!transactionHash || !chainDli) { + return null; + } + + const lifecycle = deriveReportingEntryLifecycle(record); + const detail = { + transaction_hash: transactionHash, + chain_dli: chainDli, + }; + const settlement = instruction?.on_chain_settlement ?? null; + + if (settlement?.block_number !== null && settlement?.block_number !== undefined) { + detail.block_number = settlement.block_number; + } + if (settlement?.block_timestamp) { + detail.block_timestamp = settlement.block_timestamp; + } + if ( + settlement?.confirmation_depth !== null && + settlement?.confirmation_depth !== undefined + ) { + detail.confirmation_depth = settlement.confirmation_depth; + } + if (settlement?.transfer_verification) { + detail.transfer_verification = settlement.transfer_verification; + } + + detail.finality_status = lifecycle.finalityStatus; + + if (lifecycle.finalityStatus === 'FINAL') { + if ( + settlement?.required_confirmation_depth !== null && + settlement?.required_confirmation_depth !== undefined + ) { + detail.confirmation_depth_at_finality = + settlement.required_confirmation_depth; + } + if (record?.booking_date_time) { + detail.finality_achieved_at = record.booking_date_time; + } + } + + const gasDetail = buildSpecGasDetail(record, instruction, chainAdapter); + if (gasDetail) { + detail.gas_detail = gasDetail; + } + if (instruction?.instruction_id) { + detail.instruction_id = instruction.instruction_id; + } + if (record?.travel_rule_record_id ?? instruction?.travel_rule_record_id) { + detail.travel_rule_record_id = + record?.travel_rule_record_id ?? instruction?.travel_rule_record_id; + } + if (record?.counterparty?.wallet_address) { + detail.counterparty_wallet = record.counterparty.wallet_address; + } + + return detail; +} + +function buildSpecBlockchainEntry(record, instruction = null, chainAdapter = null) { + const lifecycle = deriveReportingEntryLifecycle(record); + const token = buildSpecTokenIdentification(record, instruction); + const entry = { + entry_reference: buildSpecEntryReference(record), + credit_debit_indicator: toReportCreditDebitIndicator(record.entry_type), + entry_status: lifecycle.entryStatus, + booking_date: record.booking_date_time, + value_date: record.booking_date_time?.slice(0, 10) ?? null, + token, + token_amount: { + amount: record.settlement_amount?.amount ?? '0', + }, + }; + const instructedAmount = + instruction?.instructed_amount ?? + record?.settlement_amount ?? + null; + const chargesInformation = buildSpecChargesInformation(record, instruction); + const blockchainDetail = buildSpecBlockchainDetail( + record, + instruction, + chainAdapter, + ); + + if (instructedAmount?.amount && instructedAmount?.currency) { + entry.instructed_amount = { + amount: instructedAmount.amount, + currency: instructedAmount.currency, + }; + } + if (chargesInformation) { + entry.charges_information = chargesInformation; + } + if (record?.remittance_information) { + entry.remittance_information = record.remittance_information; + } + if (blockchainDetail) { + entry.blockchain_detail = blockchainDetail; + } + + return entry; +} + +function buildSpecBalanceAggregation(records, mode, snapshotTime) { + const totals = new Map(); + + for (const record of records) { + const key = serialize({ + token_dti: record.token?.token_dti ?? null, + token_symbol: record.token?.token_symbol ?? null, + chain_dli: record.chain_dli ?? null, + }); + const existing = totals.get(key) ?? { + token: buildSpecTokenIdentification(record, null), + bookedNet: 0, + pendingNet: 0, + }; + const amount = Number.parseFloat(record.settlement_amount?.amount ?? '0'); + const signedAmount = record.entry_type === 'DEBIT' ? -amount : amount; + const lifecycle = deriveReportingEntryLifecycle(record); + + if (lifecycle.entryStatus === 'BOOK') { + existing.bookedNet += signedAmount; + } else { + existing.pendingNet += signedAmount; + } + + totals.set(key, existing); + } + + return Array.from(totals.values()).flatMap((entry) => { + const balances = []; + const addBalance = (balanceType, amount) => { + balances.push({ + balance_type: balanceType, + token: entry.token, + token_amount: { + amount: formatTokenAmountValue(Math.abs(amount)), + }, + credit_debit_indicator: amount < 0 ? 'DBIT' : 'CRDT', + date_time: snapshotTime, + }); + }; + + if (mode === 'STATEMENT') { + addBalance('OPBD', 0); + addBalance('CLBD', entry.bookedNet); + return balances; + } + + if (mode === 'INTRADAY') { + addBalance('OPBD', 0); + } + addBalance('ITBD', entry.bookedNet); + if (entry.pendingNet !== 0) { + addBalance('XPCD', entry.bookedNet + entry.pendingNet); + } + return balances; + }); +} + +function buildReportBalanceSnapshots(records) { + return buildSpecBalanceAggregation(records, 'BALANCE', nowIso()); +} + +function buildReportingNotificationRecord(record, notificationKind, chainAdapter) { + const isDebit = notificationKind === 'DEBTOR_DEBIT'; + const accountRole = isDebit ? 'DEBTOR' : 'CREDITOR'; + const notificationId = randomUUID(); + const party = isDebit ? record.debtor : record.creditor; + const counterparty = isDebit ? record.creditor : record.debtor; + const partyAccount = isDebit ? record.debtor_account : record.creditor_account; + const counterpartyAccount = isDebit ? record.creditor_account : record.debtor_account; + const partyAgent = isDebit ? record.debtor_agent : record.creditor_agent; + const counterpartyAgent = isDebit ? record.creditor_agent : record.debtor_agent; + const triggerStatus = isDebit + ? getDebitNotificationTriggerStatus(record.debit_timing) + : 'FINAL'; + const bookingDateTime = chainAdapter.getLifecycleTimestamp(record, triggerStatus); + + return { + notification_id: notificationId, + message_family: 'camt.054_analogue', + notification_type: 'BOOKED_ENTRY', + entry_type: isDebit ? 'DEBIT' : 'CREDIT', + account_role: accountRole, + booking_status: 'BOOKED', + booking_date_time: bookingDateTime, + value_date_time: bookingDateTime, + instruction_id: record.instruction_id, + uetr: record.uetr, + end_to_end_identification: + record.payment_identification?.end_to_end_identification ?? null, + travel_rule_record_id: record.travel_rule_record_id, + status_reference: { + trigger_status: triggerStatus, + debit_timing: record.debit_timing, + current_instruction_status: record.status, + }, + party: { + name: party?.name ?? null, + lei: party?.lei ?? null, + wallet_address: partyAccount?.proxy?.identification ?? null, + }, + counterparty: { + name: counterparty?.name ?? null, + lei: counterparty?.lei ?? null, + wallet_address: counterpartyAccount?.proxy?.identification ?? null, + }, + servicing_agent: partyAgent + ? { + name: partyAgent.name ?? null, + lei: partyAgent.lei ?? null, + bic: partyAgent.bic ?? null, + } + : null, + counterparty_agent: counterpartyAgent + ? { + name: counterpartyAgent.name ?? null, + lei: counterpartyAgent.lei ?? null, + bic: counterpartyAgent.bic ?? null, + } + : null, + settlement_amount: record.interbank_settlement_amount ?? null, + chain_dli: record.blockchain_instruction?.chain_dli ?? null, + token: record.blockchain_instruction?.token ?? null, + transaction_hash: record.on_chain_settlement?.transaction_hash ?? null, + remittance_information: record.remittance_information ?? null, + traceability: buildReportingTraceability({ + instructionId: record.instruction_id, + uetr: record.uetr, + endToEndIdentification: + record.payment_identification?.end_to_end_identification ?? null, + travelRuleRecordId: record.travel_rule_record_id, + transactionHash: record.on_chain_settlement?.transaction_hash ?? null, + accountRole, + notificationId, + }), + created_at: nowIso(), + }; +} + +function upgradeReportingNotificationToFinal(notification, record, chainAdapter) { + const finalAt = chainAdapter.getLifecycleTimestamp(record, 'FINAL'); + return { + ...notification, + notification_type: 'BOOKED_ENTRY', + booking_status: 'BOOKED', + transaction_hash: record.on_chain_settlement?.transaction_hash ?? notification.transaction_hash ?? null, + status_reference: { + ...(notification.status_reference ?? {}), + current_instruction_status: 'FINAL', + finalized_at: finalAt, + }, + traceability: buildReportingTraceability({ + instructionId: record.instruction_id, + uetr: record.uetr, + endToEndIdentification: + record.payment_identification?.end_to_end_identification ?? null, + travelRuleRecordId: record.travel_rule_record_id, + transactionHash: record.on_chain_settlement?.transaction_hash ?? null, + accountRole: notification.account_role, + notificationId: notification.notification_id, + }), + updated_at: nowIso(), + }; +} + +function buildReportingResourcePaths({ + instructionId, + accountRole = null, + notificationId = null, + statementId = null, + travelRuleRecordId = null, +}) { + const instructionQuery = new URLSearchParams({ instruction_id: instructionId }); + if (accountRole) { + instructionQuery.set('account_role', accountRole); + } + + return { + instruction: `/instruction/${instructionId}`, + execution_status: `/execution-status/${instructionId}`, + finality_receipt: `/finality-receipt/${instructionId}`, + reporting_notifications: `/reporting/notifications?${instructionQuery.toString()}`, + reporting_statements: `/reporting/statements?${instructionQuery.toString()}`, + ...(notificationId + ? { + reporting_notification: `/reporting/notifications/${notificationId}`, + } + : {}), + ...(statementId + ? { + reporting_statement: `/reporting/statements/${statementId}`, + } + : {}), + ...(travelRuleRecordId + ? { + travel_rule_record: `/travel-rule/${travelRuleRecordId}`, + } + : {}), + }; +} + +function buildReportingTraceability({ + instructionId, + uetr, + endToEndIdentification, + travelRuleRecordId = null, + transactionHash = null, + accountRole = null, + notificationId = null, + statementId = null, + sourceNotificationIds = [], +}) { + return { + instruction_id: instructionId, + uetr, + end_to_end_identification: endToEndIdentification ?? null, + travel_rule_record_id: travelRuleRecordId, + transaction_hash: transactionHash, + account_role: accountRole, + ...(notificationId ? { notification_id: notificationId } : {}), + ...(statementId ? { statement_id: statementId } : {}), + ...(sourceNotificationIds.length + ? { source_notification_ids: sourceNotificationIds } + : {}), + resource_paths: buildReportingResourcePaths({ + instructionId, + accountRole, + notificationId, + statementId, + travelRuleRecordId, + }), + }; +} + +function buildReportingNotificationSummary(record) { + return { + notification_id: record.notification_id, + entry_type: record.entry_type, + account_role: record.account_role, + booking_status: record.booking_status, + booking_date_time: record.booking_date_time, + instruction_id: record.instruction_id, + uetr: record.uetr, + end_to_end_identification: record.end_to_end_identification, + travel_rule_record_id: record.travel_rule_record_id ?? null, + party_name: record.party?.name ?? null, + counterparty_name: record.counterparty?.name ?? null, + wallet_address: record.party?.wallet_address ?? null, + counterparty_wallet_address: record.counterparty?.wallet_address ?? null, + amount: record.settlement_amount?.amount ?? null, + currency: record.settlement_amount?.currency ?? null, + token_symbol: record.token?.token_symbol ?? null, + chain_dli: record.chain_dli, + transaction_hash: record.transaction_hash, + traceability: + record.traceability ?? + buildReportingTraceability({ + instructionId: record.instruction_id, + uetr: record.uetr, + endToEndIdentification: record.end_to_end_identification, + travelRuleRecordId: record.travel_rule_record_id ?? null, + transactionHash: record.transaction_hash ?? null, + accountRole: record.account_role ?? null, + notificationId: record.notification_id, + }), + }; +} + +function buildReportEntrySummary(record) { + const lifecycle = deriveReportingEntryLifecycle(record); + return { + entry_reference: buildSpecEntryReference(record), + credit_debit_indicator: toReportCreditDebitIndicator(record.entry_type), + entry_status: lifecycle.entryStatus, + booking_date: record.booking_date_time, + token_dti: record.token?.token_dti ?? null, + token_symbol: record.token?.token_symbol ?? null, + token_amount: record.settlement_amount?.amount ?? null, + finality_status: lifecycle.finalityStatus, + transaction_hash: record.transaction_hash ?? null, + instruction_id: record.instruction_id, + travel_rule_record_id: record.travel_rule_record_id ?? null, + }; +} + +function buildReportTokenStats(records) { + const aggregates = new Map(); + for (const record of records) { + const key = serialize({ + token_dti: record.token?.token_dti ?? null, + token_symbol: record.token?.token_symbol ?? null, + }); + const existing = aggregates.get(key) ?? { + token_dti: record.token?.token_dti ?? null, + token_symbol: record.token?.token_symbol ?? null, + total_entries: 0, + total_credit_entries: 0, + total_debit_entries: 0, + total_credit_amount: 0, + total_debit_amount: 0, + }; + const amount = Number.parseFloat(record.settlement_amount?.amount ?? '0'); + existing.total_entries += 1; + if (record.entry_type === 'DEBIT') { + existing.total_debit_entries += 1; + existing.total_debit_amount += amount; + } else { + existing.total_credit_entries += 1; + existing.total_credit_amount += amount; + } + aggregates.set(key, existing); + } + + return Array.from(aggregates.values()).map((entry) => { + const netAmount = entry.total_credit_amount - entry.total_debit_amount; + return { + ...(entry.token_dti ? { token_dti: entry.token_dti } : {}), + ...(entry.token_symbol ? { token_symbol: entry.token_symbol } : {}), + total_entries: entry.total_entries, + total_credit_entries: entry.total_credit_entries, + total_debit_entries: entry.total_debit_entries, + total_credit_amount: formatDecimalAmount(entry.total_credit_amount), + total_debit_amount: formatDecimalAmount(entry.total_debit_amount), + net_amount: formatDecimalAmount(netAmount), + net_credit_debit_indicator: netAmount < 0 ? 'DBIT' : 'CRDT', + }; + }); +} + +function buildStatementPeriod(notifications) { + const ordered = [...notifications].sort( + (left, right) => + Date.parse(left.booking_date_time) - Date.parse(right.booking_date_time), + ); + + return { + from: ordered[0]?.booking_date_time ?? null, + to: ordered.at(-1)?.booking_date_time ?? null, + }; +} + +function buildReportingStatementKey(record, accountRole) { + return serialize({ + instruction_id: record.instruction_id, + account_role: accountRole, + }); +} + +function buildReportingStatementRecord( + record, + notifications, + accountRole, + statementId = randomUUID(), +) { + if (!notifications.length) { + return null; + } + + const orderedNotifications = [...notifications].sort( + (left, right) => + Date.parse(left.booking_date_time) - Date.parse(right.booking_date_time), + ); + const summaryNotifications = orderedNotifications.map((notification) => + buildReportingNotificationSummary(notification), + ); + const currency = orderedNotifications[0]?.settlement_amount?.currency ?? 'XXX'; + const debitTotal = orderedNotifications + .filter((notification) => notification.entry_type === 'DEBIT') + .reduce((total, notification) => total + Number.parseFloat(notification.settlement_amount?.amount ?? '0'), 0); + const creditTotal = orderedNotifications + .filter((notification) => notification.entry_type === 'CREDIT') + .reduce((total, notification) => total + Number.parseFloat(notification.settlement_amount?.amount ?? '0'), 0); + const openingBalance = 0; + const closingBalance = creditTotal - debitTotal; + const period = buildStatementPeriod(orderedNotifications); + const sourceNotificationIds = orderedNotifications.map( + (notification) => notification.notification_id, + ); + + return { + statement_id: statementId, + statement_key: buildReportingStatementKey(record, accountRole), + message_family: 'camt.053_analogue', + statement_type: 'ACCOUNT_STATEMENT', + instruction_id: record.instruction_id, + uetr: record.uetr, + account_role: accountRole, + travel_rule_record_id: record.travel_rule_record_id ?? null, + statement_date: period.from ? period.from.slice(0, 10) : record.created_at.slice(0, 10), + period, + statement_scope: { + derivation_basis: 'BOOKED_NOTIFICATIONS', + account_role: accountRole, + source_notification_count: sourceNotificationIds.length, + source_notification_ids: sourceNotificationIds, + booking_date_time_from: period.from, + booking_date_time_to: period.to, + }, + party: orderedNotifications[0]?.party ?? null, + counterparty: orderedNotifications[0]?.counterparty ?? null, + chain_dli: orderedNotifications[0]?.chain_dli ?? null, + token: orderedNotifications[0]?.token ?? null, + transaction_hash: record.on_chain_settlement?.transaction_hash ?? null, + instruction_context: { + status: record.status, + finality_status: record.on_chain_settlement?.finality_status ?? null, + debit_timing: record.debit_timing ?? null, + end_to_end_identification: + record.payment_identification?.end_to_end_identification ?? null, + travel_rule_record_id: record.travel_rule_record_id ?? null, + related_notification_count: sourceNotificationIds.length, + }, + balance_summary: { + opening_balance: { + ...buildReportingBalance(openingBalance, currency), + }, + closing_balance: { + ...buildReportingBalance(closingBalance, currency), + }, + available_balance: { + ...buildReportingBalance(closingBalance, currency), + }, + }, + movement_summary: { + entry_count: summaryNotifications.length, + debit_total: formatDecimalAmount(debitTotal), + credit_total: formatDecimalAmount(creditTotal), + net_total: formatDecimalAmount(Math.abs(closingBalance)), + net_credit_debit_indicator: closingBalance < 0 ? 'DBIT' : 'CRDT', + }, + entries: summaryNotifications, + traceability: buildReportingTraceability({ + instructionId: record.instruction_id, + uetr: record.uetr, + endToEndIdentification: + record.payment_identification?.end_to_end_identification ?? null, + travelRuleRecordId: record.travel_rule_record_id ?? null, + transactionHash: record.on_chain_settlement?.transaction_hash ?? null, + accountRole, + statementId, + sourceNotificationIds, + }), + created_at: nowIso(), + updated_at: nowIso(), + }; +} + +function buildReportingStatementSummary(record) { + return { + statement_id: record.statement_id, + statement_key: record.statement_key, + statement_type: record.statement_type, + statement_date: record.statement_date, + instruction_id: record.instruction_id, + uetr: record.uetr, + account_role: record.account_role, + travel_rule_record_id: record.travel_rule_record_id ?? null, + wallet_address: record.party?.wallet_address ?? null, + party_name: record.party?.name ?? null, + counterparty_name: record.counterparty?.name ?? null, + chain_dli: record.chain_dli, + token_symbol: record.token?.token_symbol ?? null, + transaction_hash: record.transaction_hash, + balance_summary: record.balance_summary, + movement_summary: record.movement_summary, + period: record.period, + statement_scope: record.statement_scope ?? null, + instruction_context: record.instruction_context, + traceability: + record.traceability ?? + buildReportingTraceability({ + instructionId: record.instruction_id, + uetr: record.uetr, + endToEndIdentification: + record.instruction_context?.end_to_end_identification ?? null, + travelRuleRecordId: record.travel_rule_record_id ?? null, + transactionHash: record.transaction_hash ?? null, + accountRole: record.account_role ?? null, + statementId: record.statement_id, + sourceNotificationIds: + record.statement_scope?.source_notification_ids ?? [], + }), + created_at: record.created_at, + updated_at: record.updated_at, + }; +} + +function getReportingAggregationKey(record) { + return serialize({ + currency: record.settlement_amount?.currency ?? 'XXX', + token_dti: record.token?.token_dti ?? null, + token_symbol: record.token?.token_symbol ?? null, + }); +} + +function buildReportingMovementTotals(records) { + const totals = new Map(); + for (const record of records) { + const key = getReportingAggregationKey(record); + const existing = totals.get(key) ?? { + currency: record.settlement_amount?.currency ?? 'XXX', + token_dti: record.token?.token_dti ?? null, + token_symbol: record.token?.token_symbol ?? null, + debit_total: 0, + credit_total: 0, + notification_count: 0, + }; + const amount = Number.parseFloat(record.settlement_amount?.amount ?? '0'); + + if (record.entry_type === 'DEBIT') { + existing.debit_total += amount; + } else if (record.entry_type === 'CREDIT') { + existing.credit_total += amount; + } + + existing.notification_count += 1; + totals.set(key, existing); + } + + return Array.from(totals.values()).map((entry) => ({ + currency: entry.currency, + ...(entry.token_dti ? { token_dti: entry.token_dti } : {}), + ...(entry.token_symbol ? { token_symbol: entry.token_symbol } : {}), + debit_total: formatDecimalAmount(entry.debit_total), + credit_total: formatDecimalAmount(entry.credit_total), + net_total: formatDecimalAmount(entry.credit_total - entry.debit_total), + notification_count: entry.notification_count, + })); +} + +function buildIntradayAccountKey(record) { + return serialize({ + account_role: record.account_role, + wallet_address: record.party?.wallet_address ?? null, + party_name: record.party?.name ?? null, + }); +} + +function buildIntradayAccountViews(records) { + const accountMap = new Map(); + for (const record of records) { + const key = buildIntradayAccountKey(record); + const existing = accountMap.get(key) ?? { + account_role: record.account_role, + party_name: record.party?.name ?? null, + party_lei: record.party?.lei ?? null, + wallet_address: record.party?.wallet_address ?? null, + chain_dli: record.chain_dli ?? null, + notifications: [], + instructionIds: new Set(), + uetrs: new Set(), + travelRuleRecordIds: new Set(), + transactionHashes: new Set(), + }; + existing.notifications.push(record); + existing.instructionIds.add(record.instruction_id); + existing.uetrs.add(record.uetr); + if (record.travel_rule_record_id) { + existing.travelRuleRecordIds.add(record.travel_rule_record_id); + } + if (record.transaction_hash) { + existing.transactionHashes.add(record.transaction_hash); + } + accountMap.set(key, existing); + } + + return Array.from(accountMap.values()).map((entry) => ({ + account_role: entry.account_role, + party_name: entry.party_name, + party_lei: entry.party_lei, + wallet_address: entry.wallet_address, + chain_dli: entry.chain_dli, + notification_count: entry.notifications.length, + instruction_ids: Array.from(entry.instructionIds), + uetrs: Array.from(entry.uetrs), + travel_rule_record_ids: Array.from(entry.travelRuleRecordIds), + transaction_hashes: Array.from(entry.transactionHashes), + movement_totals: buildReportingMovementTotals(entry.notifications), + })); +} + +function buildExceptionResourcePaths({ + instructionId, + investigationCaseId = null, + returnCaseId = null, + travelRuleRecordId = null, +} = {}) { + const investigationQuery = new URLSearchParams({ + related_instruction_id: instructionId, + }); + const returnQuery = new URLSearchParams({ + original_instruction_id: instructionId, + }); + + return { + instruction: `/instruction/${instructionId}`, + execution_status: `/execution-status/${instructionId}`, + finality_receipt: `/finality-receipt/${instructionId}`, + event_outbox: `/event-outbox?instruction_id=${instructionId}`, + investigations: `/exceptions/investigations?${investigationQuery.toString()}`, + returns: `/exceptions/returns?${returnQuery.toString()}`, + ...(investigationCaseId + ? { + investigation_case: `/exceptions/investigations/${investigationCaseId}`, + } + : {}), + ...(returnCaseId + ? { + return_case: `/exceptions/returns/${returnCaseId}`, + } + : {}), + ...(travelRuleRecordId + ? { + travel_rule_record: `/travel-rule/${travelRuleRecordId}`, + } + : {}), + }; +} + +function buildExceptionTraceability({ + instructionId, + uetr, + endToEndIdentification, + travelRuleRecordId = null, + transactionHash = null, + investigationCaseId = null, + returnCaseId = null, +}) { + return { + instruction_id: instructionId, + uetr, + end_to_end_identification: endToEndIdentification ?? null, + travel_rule_record_id: travelRuleRecordId, + transaction_hash: transactionHash, + resource_paths: buildExceptionResourcePaths({ + instructionId, + investigationCaseId, + returnCaseId, + travelRuleRecordId, + }), + }; +} + +function buildExceptionCaseActivity({ status, updatedAt, summary }) { + return { + event_id: randomUUID(), + status, + updated_at: updatedAt, + summary, + }; +} + +function appendExceptionCaseActivity(history, { status, updatedAt, summary }) { + return [ + ...(Array.isArray(history) ? history : []), + buildExceptionCaseActivity({ status, updatedAt, summary }), + ]; +} + +const INVESTIGATION_CASE_ALLOWED_TRANSITIONS = { + OPEN: new Set(['IN_PROGRESS', 'WAITING_COUNTERPARTY', 'RESOLVED', 'CLOSED']), + IN_PROGRESS: new Set(['WAITING_COUNTERPARTY', 'RESOLVED', 'CLOSED']), + WAITING_COUNTERPARTY: new Set(['IN_PROGRESS', 'RESOLVED', 'CLOSED']), + RESOLVED: new Set(['CLOSED']), + CLOSED: new Set([]), +}; + +const RETURN_CASE_ALLOWED_TRANSITIONS = { + PROPOSED: new Set(['APPROVED', 'SETTLED', 'DECLINED', 'CANCELLED']), + APPROVED: new Set(['SETTLED', 'CANCELLED']), + SETTLED: new Set([]), + DECLINED: new Set([]), + CANCELLED: new Set([]), +}; + +const TOM_RETURN_TERMINAL_NON_BLOCKING_STATUSES = new Set([ + 'DECLINED', + 'CANCELLED', +]); + +const TOM_REVERSAL_TERMINAL_NON_BLOCKING_STATUSES = new Set([ + 'DECLINED', + 'CANCELLED', + 'WITHDRAWN', + 'REJECTED', +]); + +function isTomReturnCaseBlocking(record) { + if (!record || record.exception_type !== 'RETURN' || record.origin !== 'TOM') { + return false; + } + return !TOM_RETURN_TERMINAL_NON_BLOCKING_STATUSES.has(record.return_status); +} + +function isTomReversalCaseBlocking(record) { + if (!record || record.exception_type !== 'REVERSAL') { + return false; + } + return !TOM_REVERSAL_TERMINAL_NON_BLOCKING_STATUSES.has(record.reversal_status); +} + +function buildExceptionDomainError(code, message) { + const error = new Error(message); + error.code = code; + return error; +} + +function normalizeExceptionReferenceIds(value) { + if (!Array.isArray(value)) { + return []; + } + + return Array.from(new Set(value.filter(Boolean))); +} + +function assertAllowedCaseTransition(currentStatus, nextStatus, transitionMap, label) { + if (!nextStatus || nextStatus === currentStatus) { + return; + } + + const allowed = transitionMap[currentStatus] ?? new Set(); + if (!allowed.has(nextStatus)) { + throw buildExceptionDomainError( + 'CONFLICT', + `${label} cannot move from ${currentStatus} to ${nextStatus}.`, + ); + } +} + +function assertInvestigationCaseWorkflow(current, nextStatus, patch) { + assertAllowedCaseTransition( + current.case_status, + nextStatus, + INVESTIGATION_CASE_ALLOWED_TRANSITIONS, + 'Investigation case', + ); + + const requiresCounterpartyAction = + typeof patch.requires_counterparty_action === 'boolean' + ? patch.requires_counterparty_action + : current.requires_counterparty_action; + const resolutionType = patch.resolution_type ?? current.resolution_type ?? null; + const resolutionSummary = + patch.resolution_summary ?? current.resolution_summary ?? null; + + if ( + nextStatus === 'WAITING_COUNTERPARTY' && + requiresCounterpartyAction !== true + ) { + throw buildExceptionDomainError( + 'INVALID_REQUEST', + 'WAITING_COUNTERPARTY requires requires_counterparty_action to be true.', + ); + } + + if ( + ['RESOLVED', 'CLOSED'].includes(nextStatus) && + (!resolutionType || !resolutionSummary) + ) { + throw buildExceptionDomainError( + 'INVALID_REQUEST', + 'RESOLVED and CLOSED investigation cases require both resolution_type and resolution_summary.', + ); + } +} + +function assertReturnCaseWorkflow(current, nextStatus, patch) { + assertAllowedCaseTransition( + current.return_status, + nextStatus, + RETURN_CASE_ALLOWED_TRANSITIONS, + 'Return case', + ); + + const resolutionSummary = + patch.resolution_summary ?? current.resolution_summary ?? null; + const compensatingInstructionId = + patch.compensating_instruction_id ?? + current.compensating_instruction_id ?? + null; + const offChainReference = + patch.off_chain_reference ?? current.off_chain_reference ?? null; + + if ( + ['DECLINED', 'CANCELLED'].includes(nextStatus) && + !resolutionSummary + ) { + throw buildExceptionDomainError( + 'INVALID_REQUEST', + 'DECLINED and CANCELLED return cases require resolution_summary.', + ); + } + + if (nextStatus === 'SETTLED') { + if ( + current.return_method === 'ON_CHAIN_COMPENSATING_TRANSFER' && + !compensatingInstructionId + ) { + throw buildExceptionDomainError( + 'INVALID_REQUEST', + 'SETTLED on-chain compensating transfers require compensating_instruction_id.', + ); + } + + if ( + ['OFF_CHAIN_REFUND', 'MANUAL_FIAT_REMEDIATION'].includes( + current.return_method, + ) && + !offChainReference + ) { + throw buildExceptionDomainError( + 'INVALID_REQUEST', + `SETTLED ${current.return_method} cases require off_chain_reference.`, + ); + } + } +} + +function buildInvestigationCaseRecord(submission, instruction, linkedReturnCase = null) { + const openedAt = nowIso(); + const investigationCaseId = randomUUID(); + const affectedNotificationIds = normalizeExceptionReferenceIds( + submission.affected_notification_ids, + ); + const affectedStatementIds = normalizeExceptionReferenceIds( + submission.affected_statement_ids, + ); + return { + investigation_case_id: investigationCaseId, + exception_case_id: investigationCaseId, + exception_type: 'INVESTIGATION', + case_type: submission.case_type, + case_status: 'OPEN', + priority: submission.priority ?? 'NORMAL', + requires_counterparty_action: submission.requires_counterparty_action === true, + resolution_type: null, + resolution_summary: null, + linked_return_case_id: linkedReturnCase?.return_case_id ?? null, + reason_code: submission.reason_code, + narrative: submission.narrative, + opened_by: submission.opened_by ?? null, + current_owner: submission.current_owner ?? submission.opened_by ?? null, + assigned_team: submission.assigned_team ?? 'EXCEPTIONS_OPERATIONS', + next_action_due_at: submission.next_action_due_at ?? null, + counterparty_reference: submission.counterparty_reference ?? null, + reporting_follow_up_required: submission.reporting_follow_up_required === true, + affected_notification_ids: affectedNotificationIds, + affected_statement_ids: affectedStatementIds, + counterparty: submission.counterparty ?? null, + opened_at: openedAt, + updated_at: openedAt, + related_instruction_id: instruction.instruction_id, + related_uetr: instruction.uetr, + related_travel_rule_record_id: instruction.travel_rule_record_id ?? null, + related_transaction_hash: instruction.on_chain_settlement?.transaction_hash ?? null, + end_to_end_identification: + instruction.payment_identification?.end_to_end_identification ?? null, + chain_dli: instruction.blockchain_instruction?.chain_dli ?? null, + token: instruction.blockchain_instruction?.token ?? null, + original_instruction_status: instruction.status, + traceability: buildExceptionTraceability({ + instructionId: instruction.instruction_id, + uetr: instruction.uetr, + endToEndIdentification: + instruction.payment_identification?.end_to_end_identification ?? null, + travelRuleRecordId: instruction.travel_rule_record_id ?? null, + transactionHash: instruction.on_chain_settlement?.transaction_hash ?? null, + investigationCaseId, + returnCaseId: linkedReturnCase?.return_case_id ?? null, + }), + status_history: [ + buildExceptionCaseActivity({ + status: 'OPEN', + updatedAt: openedAt, + summary: 'Investigation case opened.', + }), + ], + }; +} + +function buildInvestigationCaseSummary(record) { + return { + investigation_case_id: record.investigation_case_id, + exception_case_id: record.exception_case_id, + case_type: record.case_type, + case_status: record.case_status, + priority: record.priority, + requires_counterparty_action: record.requires_counterparty_action, + reason_code: record.reason_code, + opened_at: record.opened_at, + updated_at: record.updated_at, + related_instruction_id: record.related_instruction_id, + related_uetr: record.related_uetr, + related_travel_rule_record_id: record.related_travel_rule_record_id, + linked_return_case_id: record.linked_return_case_id ?? null, + current_owner: record.current_owner ?? null, + assigned_team: record.assigned_team ?? null, + next_action_due_at: record.next_action_due_at ?? null, + reporting_follow_up_required: record.reporting_follow_up_required === true, + affected_notification_count: record.affected_notification_ids?.length ?? 0, + affected_statement_count: record.affected_statement_ids?.length ?? 0, + traceability: + record.traceability ?? + buildExceptionTraceability({ + instructionId: record.related_instruction_id, + uetr: record.related_uetr, + endToEndIdentification: record.end_to_end_identification ?? null, + travelRuleRecordId: record.related_travel_rule_record_id ?? null, + transactionHash: record.related_transaction_hash ?? null, + investigationCaseId: record.investigation_case_id, + returnCaseId: record.linked_return_case_id ?? null, + }), + }; +} + +function buildReturnCaseRecord(submission, instruction, linkedInvestigationCase = null) { + const openedAt = nowIso(); + const returnCaseId = randomUUID(); + const affectedNotificationIds = normalizeExceptionReferenceIds( + submission.affected_notification_ids, + ); + const affectedStatementIds = normalizeExceptionReferenceIds( + submission.affected_statement_ids, + ); + return { + return_case_id: returnCaseId, + exception_case_id: returnCaseId, + exception_type: 'RETURN', + return_type: submission.return_type, + return_method: submission.return_method, + return_status: 'PROPOSED', + reason_code: submission.reason_code, + narrative: submission.narrative, + resolution_summary: null, + opened_by: submission.opened_by ?? null, + current_owner: submission.current_owner ?? submission.opened_by ?? null, + assigned_team: submission.assigned_team ?? 'EXCEPTIONS_OPERATIONS', + next_action_due_at: submission.next_action_due_at ?? null, + counterparty_reference: submission.counterparty_reference ?? null, + reporting_follow_up_required: submission.reporting_follow_up_required === true, + affected_notification_ids: affectedNotificationIds, + affected_statement_ids: affectedStatementIds, + counterparty: submission.counterparty ?? null, + opened_at: openedAt, + updated_at: openedAt, + related_instruction_id: instruction.instruction_id, + related_uetr: instruction.uetr, + related_travel_rule_record_id: instruction.travel_rule_record_id ?? null, + related_transaction_hash: instruction.on_chain_settlement?.transaction_hash ?? null, + original_instruction_id: instruction.instruction_id, + original_uetr: instruction.uetr, + original_transaction_hash: instruction.on_chain_settlement?.transaction_hash ?? null, + end_to_end_identification: + instruction.payment_identification?.end_to_end_identification ?? null, + return_amount: submission.return_amount, + return_asset: submission.return_asset ?? instruction.blockchain_instruction?.token ?? null, + linked_investigation_case_id: + linkedInvestigationCase?.investigation_case_id ?? null, + compensating_instruction_id: submission.compensating_instruction_id ?? null, + off_chain_reference: submission.off_chain_reference ?? null, + original_instruction_status: instruction.status, + chain_dli: instruction.blockchain_instruction?.chain_dli ?? null, + traceability: buildExceptionTraceability({ + instructionId: instruction.instruction_id, + uetr: instruction.uetr, + endToEndIdentification: + instruction.payment_identification?.end_to_end_identification ?? null, + travelRuleRecordId: instruction.travel_rule_record_id ?? null, + transactionHash: instruction.on_chain_settlement?.transaction_hash ?? null, + investigationCaseId: + linkedInvestigationCase?.investigation_case_id ?? null, + returnCaseId, + }), + status_history: [ + buildExceptionCaseActivity({ + status: 'PROPOSED', + updatedAt: openedAt, + summary: 'Return case opened.', + }), + ], + }; +} + +function buildReturnCaseSummary(record) { + return { + return_case_id: record.return_case_id, + exception_case_id: record.exception_case_id, + return_type: record.return_type, + return_method: record.return_method, + return_status: record.return_status, + reason_code: record.reason_code, + opened_at: record.opened_at, + updated_at: record.updated_at, + original_instruction_id: record.original_instruction_id, + original_uetr: record.original_uetr, + linked_investigation_case_id: record.linked_investigation_case_id ?? null, + compensating_instruction_id: record.compensating_instruction_id ?? null, + off_chain_reference: record.off_chain_reference ?? null, + current_owner: record.current_owner ?? null, + assigned_team: record.assigned_team ?? null, + next_action_due_at: record.next_action_due_at ?? null, + reporting_follow_up_required: record.reporting_follow_up_required === true, + affected_notification_count: record.affected_notification_ids?.length ?? 0, + affected_statement_count: record.affected_statement_ids?.length ?? 0, + traceability: + record.traceability ?? + buildExceptionTraceability({ + instructionId: record.related_instruction_id, + uetr: record.related_uetr, + endToEndIdentification: record.end_to_end_identification ?? null, + travelRuleRecordId: record.related_travel_rule_record_id ?? null, + transactionHash: record.related_transaction_hash ?? null, + investigationCaseId: record.linked_investigation_case_id ?? null, + returnCaseId: record.return_case_id, + }), + }; +} + +function deriveTomReturnTypeFromCode(code) { + if (code === 'CANC' || code === 'AC04' || code === 'AM05') { + return 'CUSTOMER_REFUND'; + } + if (code === 'AC01' || code === 'BE04' || code === 'RC03' || code === 'RR04') { + return 'BENEFICIARY_REJECTED'; + } + if (code === 'FRAD') { + return 'COMPLIANCE_REMEDIATION'; + } + if (code === 'TECH') { + return 'SETTLEMENT_CORRECTION'; + } + return 'CUSTOMER_REFUND'; +} + +function buildTomReturnCaseRecord({ + instruction, + body, + compensatingInstruction, +}) { + const openedAt = nowIso(); + const returnCaseId = randomUUID(); + const returnIdentification = body.return_identification ?? null; + const returnType = deriveTomReturnTypeFromCode(body.return_reason?.code); + const additionalNarrative = + Array.isArray(body.return_reason?.additional_information) && + body.return_reason.additional_information.length > 0 + ? body.return_reason.additional_information.join(' | ') + : null; + return { + return_case_id: returnCaseId, + exception_case_id: returnCaseId, + exception_type: 'RETURN', + origin: 'TOM', + return_type: returnType, + return_method: 'ON_CHAIN_COMPENSATING_TRANSFER', + return_status: 'APPROVED', + return_identification: returnIdentification, + reason_code: body.return_reason?.code ?? null, + return_reason: body.return_reason ?? null, + narrative: + additionalNarrative ?? `Tom v1.2 return for ${body.return_reason?.code ?? 'UNSPECIFIED'} reason.`, + resolution_summary: 'Tom v1.2 compensating instruction issued.', + opened_by: null, + current_owner: null, + assigned_team: 'EXCEPTIONS_OPERATIONS', + next_action_due_at: null, + counterparty_reference: null, + reporting_follow_up_required: false, + affected_notification_ids: [], + affected_statement_ids: [], + counterparty: null, + opened_at: openedAt, + updated_at: openedAt, + settled_at: null, + related_instruction_id: instruction.instruction_id, + related_uetr: instruction.uetr, + related_travel_rule_record_id: instruction.travel_rule_record_id ?? null, + related_transaction_hash: + instruction.on_chain_settlement?.transaction_hash ?? null, + original_instruction_id: instruction.instruction_id, + original_uetr: instruction.uetr, + original_transaction_hash: + instruction.on_chain_settlement?.transaction_hash ?? null, + end_to_end_identification: + instruction.payment_identification?.end_to_end_identification ?? null, + return_amount: body.returned_amount, + returned_amount: body.returned_amount, + return_asset: instruction.blockchain_instruction?.token ?? null, + linked_investigation_case_id: null, + compensating_instruction_id: compensatingInstruction.instruction_id, + compensating_uetr: compensatingInstruction.uetr, + off_chain_reference: null, + original_instruction_status: instruction.status, + chain_dli: instruction.blockchain_instruction?.chain_dli ?? null, + traceability: buildExceptionTraceability({ + instructionId: instruction.instruction_id, + uetr: instruction.uetr, + endToEndIdentification: + instruction.payment_identification?.end_to_end_identification ?? null, + travelRuleRecordId: instruction.travel_rule_record_id ?? null, + transactionHash: + instruction.on_chain_settlement?.transaction_hash ?? null, + investigationCaseId: null, + returnCaseId, + }), + status_history: [ + buildExceptionCaseActivity({ + status: 'APPROVED', + updatedAt: openedAt, + summary: 'Tom v1.2 return accepted and compensating instruction created.', + }), + ], + }; +} + +function buildTomReversalCaseRecord({ instruction, body }) { + const openedAt = nowIso(); + const reversalCaseId = randomUUID(); + const reversalIdentification = + body.reversal_identification ?? `REV-${reversalCaseId}`; + const receiverResponseExpectedBy = new Date( + Date.parse(openedAt) + 48 * 60 * 60 * 1000, + ).toISOString(); + const additionalNarrative = + Array.isArray(body.reversal_reason?.additional_information) && + body.reversal_reason.additional_information.length > 0 + ? body.reversal_reason.additional_information.join(' | ') + : null; + return { + return_case_id: reversalCaseId, + exception_case_id: reversalCaseId, + reversal_request_id: reversalCaseId, + reversal_identification: reversalIdentification, + exception_type: 'REVERSAL', + origin: 'TOM', + reversal_status: 'REQUESTED', + return_status: 'REQUESTED', + reason_code: body.reversal_reason?.code ?? null, + reversal_reason: body.reversal_reason ?? null, + narrative: + additionalNarrative ?? + `Tom v1.2 reversal request for ${body.reversal_reason?.code ?? 'UNSPECIFIED'} reason.`, + resolution_summary: null, + webhook_url: typeof body.webhook_url === 'string' ? body.webhook_url : null, + opened_by: null, + current_owner: null, + assigned_team: 'EXCEPTIONS_OPERATIONS', + requested_at: openedAt, + receiver_response_expected_by: receiverResponseExpectedBy, + next_action_due_at: receiverResponseExpectedBy, + affected_notification_ids: [], + affected_statement_ids: [], + counterparty: null, + opened_at: openedAt, + updated_at: openedAt, + related_instruction_id: instruction.instruction_id, + related_uetr: instruction.uetr, + related_travel_rule_record_id: instruction.travel_rule_record_id ?? null, + related_transaction_hash: + instruction.on_chain_settlement?.transaction_hash ?? null, + original_instruction_id: instruction.instruction_id, + original_uetr: instruction.uetr, + original_transaction_hash: + instruction.on_chain_settlement?.transaction_hash ?? null, + end_to_end_identification: + instruction.payment_identification?.end_to_end_identification ?? null, + reversed_amount: body.reversed_amount, + return_amount: body.reversed_amount, + return_asset: instruction.blockchain_instruction?.token ?? null, + linked_investigation_case_id: null, + compensating_instruction_id: null, + compensating_instruction_status: null, + off_chain_reference: null, + original_instruction_status: instruction.status, + chain_dli: instruction.blockchain_instruction?.chain_dli ?? null, + traceability: buildExceptionTraceability({ + instructionId: instruction.instruction_id, + uetr: instruction.uetr, + endToEndIdentification: + instruction.payment_identification?.end_to_end_identification ?? null, + travelRuleRecordId: instruction.travel_rule_record_id ?? null, + transactionHash: + instruction.on_chain_settlement?.transaction_hash ?? null, + investigationCaseId: null, + returnCaseId: reversalCaseId, + }), + status_history: [ + buildExceptionCaseActivity({ + status: 'REQUESTED', + updatedAt: openedAt, + summary: 'Tom v1.2 reversal request received.', + }), + ], + }; +} + +function buildTomReturnResponse(record) { + const response = { + compensating_instruction_id: record.compensating_instruction_id, + compensating_uetr: record.compensating_uetr, + original_instruction_id: record.original_instruction_id, + status: 'PENDING', + accepted_at: record.opened_at, + }; + if (record.return_identification) { + response.return_identification = record.return_identification; + } + return response; +} + +function buildTomReversalResponse(record) { + return { + reversal_request_id: record.reversal_request_id ?? record.return_case_id, + original_instruction_id: record.original_instruction_id, + reversal_identification: record.reversal_identification, + status: record.reversal_status, + requested_at: record.requested_at ?? record.opened_at, + receiver_response_expected_by: record.receiver_response_expected_by ?? null, + }; +} + +function buildTomReversalStatusResponse(record) { + if (!record) { + return null; + } + const status = record.reversal_status ?? 'REQUESTED'; + const response = { + reversal_request_id: record.reversal_request_id ?? record.return_case_id, + original_instruction_id: record.original_instruction_id, + status, + requested_at: record.requested_at ?? record.opened_at, + }; + if (record.responded_at) { + response.responded_at = record.responded_at; + } + if (record.rejection_reason_code) { + response.rejection_reason_code = record.rejection_reason_code; + } + if (record.rejection_reason) { + response.rejection_reason = record.rejection_reason; + } + if ( + ['ACCEPTED', 'COMPLETED'].includes(status) && + record.compensating_instruction_id + ) { + response.compensating_instruction_id = record.compensating_instruction_id; + response.compensating_instruction_status = + record.compensating_instruction_status ?? null; + } + return response; +} + +function buildOutboxEvent({ + eventType, + payloadSchema, + resourcePath, + createdAt, + record, + instructionId, + uetr, + payload, +}) { + return { + event_id: randomUUID(), + event_type: eventType, + payload_schema: payloadSchema, + instruction_id: instructionId ?? record?.instruction_id ?? null, + uetr: uetr ?? record?.uetr ?? null, + created_at: createdAt, + delivery_state: 'PENDING', + resource_path: resourcePath, + payload, + }; +} + +function normalizeWebhookEventTypes(eventTypes) { + if (!Array.isArray(eventTypes) || eventTypes.length === 0) { + return [...WEBHOOK_EVENT_TYPES]; + } + + return eventTypes.filter((eventType) => WEBHOOK_EVENT_TYPES.includes(eventType)); +} + +function buildWebhookSubscriptionRecord(submission) { + const timestamp = nowIso(); + return { + subscription_id: randomUUID(), + url: submission.url, + description: submission.description ?? null, + active: submission.active !== false, + signing_secret: submission.signing_secret, + signing_secret_last4: submission.signing_secret.slice(-4), + subscribed_event_types: normalizeWebhookEventTypes(submission.subscribed_event_types), + created_at: timestamp, + updated_at: timestamp, + max_attempts: Number.isInteger(submission.max_attempts) && submission.max_attempts > 0 + ? submission.max_attempts + : 5, + last_delivery_at: null, + filter_wallet_address: submission.filter_wallet_address ?? null, + filter_chain_dli: submission.filter_chain_dli ?? null, + subscription_kind: submission.subscription_kind ?? 'GENERIC', + query_identification: submission.query_identification ?? null, + }; +} + +function buildWebhookSubscriptionResponse(record, { includeSecret = false } = {}) { + return { + subscription_id: record.subscription_id, + url: record.url, + description: record.description, + active: record.active, + signing_secret_last4: record.signing_secret_last4, + subscribed_event_types: record.subscribed_event_types, + created_at: record.created_at, + updated_at: record.updated_at, + max_attempts: record.max_attempts, + last_delivery_at: record.last_delivery_at, + ...(includeSecret ? { signing_secret: record.signing_secret } : {}), + }; +} + +function buildWebhookDeliveryRecord(event, subscription) { + return { + delivery_id: randomUUID(), + subscription_id: subscription.subscription_id, + event_id: event.event_id, + event_type: event.event_type, + instruction_id: event.instruction_id, + uetr: event.uetr, + target_url: subscription.url, + delivery_state: 'PENDING', + attempt_count: 0, + last_attempt_at: null, + next_attempt_at: event.created_at, + response_status: null, + response_body_excerpt: null, + last_error: null, + last_signature: null, + failure_category: null, + terminal_reason: null, + dead_lettered_at: null, + delivery_guarantee: WEBHOOK_DELIVERY_GUARANTEE, + created_at: event.created_at, + updated_at: event.created_at, + }; +} + +function doesWebhookSubscriptionMatchEvent(subscription, event) { + if (!subscription.active) { + return false; + } + + if (!subscription.subscribed_event_types.includes(event.event_type)) { + return false; + } + + if (event.event_type === 'reporting_notification.created') { + const walletFilter = subscription.filter_wallet_address; + const chainFilter = subscription.filter_chain_dli; + const eventWallet = event.payload?.party?.wallet_address ?? null; + const eventChain = event.payload?.chain_dli ?? null; + + if (walletFilter && eventWallet !== walletFilter) { + return false; + } + if (chainFilter && eventChain !== chainFilter) { + return false; + } + } + + if (event.event_type === 'reporting_statement.ready') { + const walletFilter = subscription.filter_wallet_address; + const chainFilter = subscription.filter_chain_dli; + const subscriptionQueryIdentification = + subscription.query_identification ?? null; + const eventWallet = event.payload?.wallet_address ?? null; + const eventChain = event.payload?.chain_dli ?? null; + const eventQueryIdentification = + event.payload?.query_identification ?? null; + + if (walletFilter && eventWallet !== walletFilter) { + return false; + } + if (chainFilter && eventChain !== chainFilter) { + return false; + } + if ( + subscriptionQueryIdentification && + eventQueryIdentification !== subscriptionQueryIdentification + ) { + return false; + } + } + + return true; +} + +function buildWebhookEnvelope(event, delivery) { + return { + delivery_id: delivery.delivery_id, + delivery_attempt: delivery.attempt_count + 1, + event_id: event.event_id, + event_type: event.event_type, + payload_schema: event.payload_schema, + instruction_id: event.instruction_id, + uetr: event.uetr, + created_at: event.created_at, + payload: event.payload, + }; +} + +function buildWebhookDeliveryPayload({ + event, + delivery, + subscription, + reportingNotificationPayload = null, +}) { + if ( + subscription?.subscription_kind === 'REPORT_NOTIFICATION' && + event?.event_type === 'reporting_notification.created' && + reportingNotificationPayload + ) { + return { + payload_mode: 'spec_message', + body_object: reportingNotificationPayload, + }; + } + + if ( + subscription?.subscription_kind === 'REPORT_STATEMENT_CALLBACK' && + event?.event_type === 'reporting_statement.ready' && + event?.payload?.statement + ) { + return { + payload_mode: 'spec_message', + body_object: event.payload.statement, + }; + } + + return { + payload_mode: 'event_envelope', + body_object: buildWebhookEnvelope(event, delivery), + }; +} + +function shouldEmitExecutionStatusEvent(record, previousRecord) { + if (!previousRecord) { + return true; + } + + return ( + record.status !== previousRecord.status || + (record.failure_reason ?? null) !== (previousRecord.failure_reason ?? null) || + (record.updated_at ?? null) !== (previousRecord.updated_at ?? null) + ); +} + +function shouldEmitFinalityReceiptEvent(record, previousRecord) { + if (!previousRecord) { + return true; + } + + const currentSettlement = record.on_chain_settlement ?? {}; + const previousSettlement = previousRecord.on_chain_settlement ?? {}; + + return ( + record.status !== previousRecord.status || + (currentSettlement.transaction_hash ?? null) !== + (previousSettlement.transaction_hash ?? null) || + (currentSettlement.block_number ?? null) !== + (previousSettlement.block_number ?? null) || + (currentSettlement.block_timestamp ?? null) !== + (previousSettlement.block_timestamp ?? null) || + (currentSettlement.confirmation_depth ?? null) !== + (previousSettlement.confirmation_depth ?? null) || + (currentSettlement.required_confirmation_depth ?? null) !== + (previousSettlement.required_confirmation_depth ?? null) || + (currentSettlement.finality_status ?? null) !== + (previousSettlement.finality_status ?? null) + ); +} + +export class ReferenceStore { + constructor({ + dbPath = ':memory:', + chainAdapter = null, + webhookRetryScheduleMs = DEFAULT_WEBHOOK_RETRY_SCHEDULE_MS, + } = {}) { + if (dbPath !== ':memory:') { + mkdirSync(dirname(dbPath), { recursive: true }); + } + + this.chainAdapter = normalizeChainAdapter(chainAdapter); + this.webhookRetryScheduleMs = normalizeRetryScheduleMs(webhookRetryScheduleMs); + this.inFlightInstructionCreations = new Map(); + this.db = new DatabaseSync(dbPath); + this.db.exec(` + CREATE TABLE IF NOT EXISTS travel_rule_records ( + record_id TEXT PRIMARY KEY, + status TEXT NOT NULL, + submitted_at TEXT NOT NULL, + last_updated_at TEXT NOT NULL, + record_json TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS instruction_quotes ( + quote_id TEXT PRIMARY KEY, + valid_until TEXT NOT NULL, + created_at TEXT NOT NULL, + request_json TEXT NOT NULL, + response_json TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS instructions ( + instruction_id TEXT PRIMARY KEY, + status TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + instruction_json TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS event_outbox ( + event_id TEXT PRIMARY KEY, + event_type TEXT NOT NULL, + instruction_id TEXT NOT NULL, + created_at TEXT NOT NULL, + event_json TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS webhook_subscriptions ( + subscription_id TEXT PRIMARY KEY, + url TEXT NOT NULL, + active INTEGER NOT NULL, + updated_at TEXT NOT NULL, + subscription_json TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS webhook_deliveries ( + delivery_id TEXT PRIMARY KEY, + subscription_id TEXT NOT NULL, + event_id TEXT NOT NULL, + event_type TEXT NOT NULL, + instruction_id TEXT NOT NULL, + delivery_state TEXT NOT NULL, + next_attempt_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + delivery_json TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS reporting_notifications ( + notification_id TEXT PRIMARY KEY, + instruction_id TEXT NOT NULL, + booking_date_time TEXT NOT NULL, + notification_json TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS reporting_statements ( + statement_id TEXT PRIMARY KEY, + statement_key TEXT NOT NULL UNIQUE, + instruction_id TEXT NOT NULL, + account_role TEXT NOT NULL, + statement_date TEXT NOT NULL, + updated_at TEXT NOT NULL, + statement_json TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS investigation_cases ( + investigation_case_id TEXT PRIMARY KEY, + related_instruction_id TEXT NOT NULL, + opened_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + case_json TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS return_cases ( + return_case_id TEXT PRIMARY KEY, + related_instruction_id TEXT NOT NULL, + opened_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + case_json TEXT NOT NULL + ); + `); + + this.insertTravelRuleStmt = this.db.prepare(` + INSERT INTO travel_rule_records ( + record_id, status, submitted_at, last_updated_at, record_json + ) VALUES (?, ?, ?, ?, ?) + `); + this.updateTravelRuleStmt = this.db.prepare(` + UPDATE travel_rule_records + SET status = ?, last_updated_at = ?, record_json = ? + WHERE record_id = ? + `); + this.getTravelRuleStmt = this.db.prepare(` + SELECT record_json FROM travel_rule_records WHERE record_id = ? + `); + this.listTravelRulesStmt = this.db.prepare(` + SELECT record_json FROM travel_rule_records ORDER BY submitted_at DESC + `); + + this.insertQuoteStmt = this.db.prepare(` + INSERT INTO instruction_quotes ( + quote_id, valid_until, created_at, request_json, response_json + ) VALUES (?, ?, ?, ?, ?) + `); + this.getQuoteStmt = this.db.prepare(` + SELECT response_json FROM instruction_quotes WHERE quote_id = ? + `); + + this.insertInstructionStmt = this.db.prepare(` + INSERT INTO instructions ( + instruction_id, status, created_at, updated_at, instruction_json + ) VALUES (?, ?, ?, ?, ?) + `); + this.updateInstructionStmt = this.db.prepare(` + UPDATE instructions + SET status = ?, updated_at = ?, instruction_json = ? + WHERE instruction_id = ? + `); + this.getInstructionStmt = this.db.prepare(` + SELECT instruction_json FROM instructions WHERE instruction_id = ? + `); + this.listInstructionsStmt = this.db.prepare(` + SELECT instruction_json FROM instructions ORDER BY created_at DESC + `); + this.insertOutboxEventStmt = this.db.prepare(` + INSERT INTO event_outbox ( + event_id, event_type, instruction_id, created_at, event_json + ) VALUES (?, ?, ?, ?, ?) + `); + this.getOutboxEventStmt = this.db.prepare(` + SELECT event_json FROM event_outbox WHERE event_id = ? + `); + this.listOutboxEventsStmt = this.db.prepare(` + SELECT event_json FROM event_outbox ORDER BY created_at DESC, event_id DESC + `); + this.insertWebhookSubscriptionStmt = this.db.prepare(` + INSERT INTO webhook_subscriptions ( + subscription_id, url, active, updated_at, subscription_json + ) VALUES (?, ?, ?, ?, ?) + `); + this.updateWebhookSubscriptionStmt = this.db.prepare(` + UPDATE webhook_subscriptions + SET url = ?, active = ?, updated_at = ?, subscription_json = ? + WHERE subscription_id = ? + `); + this.getWebhookSubscriptionStmt = this.db.prepare(` + SELECT subscription_json FROM webhook_subscriptions WHERE subscription_id = ? + `); + this.listWebhookSubscriptionsStmt = this.db.prepare(` + SELECT subscription_json FROM webhook_subscriptions ORDER BY updated_at DESC, subscription_id DESC + `); + this.insertWebhookDeliveryStmt = this.db.prepare(` + INSERT INTO webhook_deliveries ( + delivery_id, subscription_id, event_id, event_type, instruction_id, + delivery_state, next_attempt_at, updated_at, delivery_json + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + this.updateWebhookDeliveryStmt = this.db.prepare(` + UPDATE webhook_deliveries + SET delivery_state = ?, next_attempt_at = ?, updated_at = ?, delivery_json = ? + WHERE delivery_id = ? + `); + this.getWebhookDeliveryStmt = this.db.prepare(` + SELECT delivery_json FROM webhook_deliveries WHERE delivery_id = ? + `); + this.listWebhookDeliveriesStmt = this.db.prepare(` + SELECT delivery_json FROM webhook_deliveries ORDER BY updated_at DESC, delivery_id DESC + `); + this.insertReportingNotificationStmt = this.db.prepare(` + INSERT INTO reporting_notifications ( + notification_id, instruction_id, booking_date_time, notification_json + ) VALUES (?, ?, ?, ?) + `); + this.updateReportingNotificationStmt = this.db.prepare(` + UPDATE reporting_notifications + SET notification_json = ? + WHERE notification_id = ? + `); + this.getReportingNotificationStmt = this.db.prepare(` + SELECT notification_json FROM reporting_notifications WHERE notification_id = ? + `); + this.listReportingNotificationsStmt = this.db.prepare(` + SELECT notification_json FROM reporting_notifications ORDER BY booking_date_time DESC, notification_id DESC + `); + this.insertReportingStatementStmt = this.db.prepare(` + INSERT INTO reporting_statements ( + statement_id, statement_key, instruction_id, account_role, statement_date, updated_at, statement_json + ) VALUES (?, ?, ?, ?, ?, ?, ?) + `); + this.updateReportingStatementStmt = this.db.prepare(` + UPDATE reporting_statements + SET statement_date = ?, updated_at = ?, statement_json = ? + WHERE statement_key = ? + `); + this.getReportingStatementStmt = this.db.prepare(` + SELECT statement_json FROM reporting_statements WHERE statement_id = ? + `); + this.getReportingStatementByKeyStmt = this.db.prepare(` + SELECT statement_json FROM reporting_statements WHERE statement_key = ? + `); + this.listReportingStatementsStmt = this.db.prepare(` + SELECT statement_json FROM reporting_statements ORDER BY statement_date DESC, updated_at DESC, statement_id DESC + `); + this.insertInvestigationCaseStmt = this.db.prepare(` + INSERT INTO investigation_cases ( + investigation_case_id, related_instruction_id, opened_at, updated_at, case_json + ) VALUES (?, ?, ?, ?, ?) + `); + this.updateInvestigationCaseStmt = this.db.prepare(` + UPDATE investigation_cases + SET updated_at = ?, case_json = ? + WHERE investigation_case_id = ? + `); + this.getInvestigationCaseStmt = this.db.prepare(` + SELECT case_json FROM investigation_cases WHERE investigation_case_id = ? + `); + this.listInvestigationCasesStmt = this.db.prepare(` + SELECT case_json FROM investigation_cases ORDER BY updated_at DESC, investigation_case_id DESC + `); + this.insertReturnCaseStmt = this.db.prepare(` + INSERT INTO return_cases ( + return_case_id, related_instruction_id, opened_at, updated_at, case_json + ) VALUES (?, ?, ?, ?, ?) + `); + this.updateReturnCaseStmt = this.db.prepare(` + UPDATE return_cases + SET updated_at = ?, case_json = ? + WHERE return_case_id = ? + `); + this.getReturnCaseStmt = this.db.prepare(` + SELECT case_json FROM return_cases WHERE return_case_id = ? + `); + this.listReturnCasesStmt = this.db.prepare(` + SELECT case_json FROM return_cases ORDER BY updated_at DESC, return_case_id DESC + `); + } + + close() { + this.db.close(); + } + + createTravelRuleRecord(submission) { + const timestamp = nowIso(); + const normalized = normalizeTravelRuleSubmission(submission); + + const record = { + record_id: randomUUID(), + submitted_at: timestamp, + last_updated_at: timestamp, + submission_timing: normalized.submission_timing, + status: 'SUBMITTED', + travel_rule_data: normalized.travel_rule_data, + submitting_vasp: normalized.submitting_vasp, + callbacks: [], + correction_of_callback_ref: normalized.correction_of_callback_ref, + }; + + this.insertTravelRuleStmt.run( + record.record_id, + record.status, + record.submitted_at, + record.last_updated_at, + serialize(record), + ); + + return record; + } + + getTravelRuleRecord(recordId) { + const row = this.getTravelRuleStmt.get(recordId); + return row ? parseJson(row.record_json, null) : null; + } + + updateTravelRuleRecord(recordId, submission) { + const current = this.getTravelRuleRecord(recordId); + if (!current) { + return null; + } + + const normalized = normalizeTravelRuleSubmission(submission); + const updated = { + ...current, + last_updated_at: nowIso(), + submission_timing: normalized.submission_timing, + status: 'SUBMITTED', + travel_rule_data: normalized.travel_rule_data, + correction_of_callback_ref: normalized.correction_of_callback_ref, + }; + + this.updateTravelRuleStmt.run( + updated.status, + updated.last_updated_at, + serialize(updated), + recordId, + ); + + return updated; + } + + appendTravelRuleCallback(recordId, callbackSubmission) { + const current = this.getTravelRuleRecord(recordId); + if (!current) { + return null; + } + + if (!TRAVEL_RULE_CALLBACK_STATUSES.has(callbackSubmission.callback_status)) { + throw new Error('Unsupported callback_status'); + } + + if (current.status === 'ACCEPTED') { + const error = new Error('ACCEPTED records do not accept superseding callbacks.'); + error.code = 'CONFLICT'; + throw error; + } + + const callback = { + callback_status: callbackSubmission.callback_status, + callback_timestamp: callbackSubmission.callback_timestamp ?? nowIso(), + description: callbackSubmission.description ?? null, + review_reason: callbackSubmission.review_reason ?? null, + receiving_vasp: callbackSubmission.receiving_vasp ?? null, + receiving_vasp_record_ref: + callbackSubmission.receiving_vasp_record_ref ?? null, + rejection_reasons: callbackSubmission.rejection_reasons ?? [], + }; + + const callbackRecordedAt = nowIso(); + const updated = { + ...current, + status: callback.callback_status, + last_updated_at: callbackRecordedAt, + callbacks: [...(current.callbacks ?? []), callback], + }; + + this.updateTravelRuleStmt.run( + updated.status, + updated.last_updated_at, + serialize(updated), + recordId, + ); + + return { + record: updated, + receipt: buildTravelRuleCallbackReceipt( + updated, + current.status, + callbackRecordedAt, + ), + }; + } + + searchTravelRuleRecords(filters = {}) { + const rows = this.listTravelRulesStmt.all(); + const records = rows.map((row) => parseJson(row.record_json, null)).filter(Boolean); + const statuses = parseListFilter(filters.status); + const callbackStatuses = parseListFilter(filters.callback_status); + const filtered = records.filter((record) => { + const data = getTravelRuleData(record); + if (statuses.length && !statuses.includes(record.status)) { + return false; + } + + if (filters.record_id && record.record_id !== filters.record_id) { + return false; + } + + const latestCallbackStatus = getTravelRuleLatestCallbackStatus(record); + if (callbackStatuses.length && !callbackStatuses.includes(latestCallbackStatus)) { + return false; + } + + if ( + filters.submission_timing && + record.submission_timing !== filters.submission_timing + ) { + return false; + } + + if ( + filters.submitted_from && + Date.parse(record.submitted_at) < Date.parse(filters.submitted_from) + ) { + return false; + } + + if ( + filters.submitted_to && + Date.parse(record.submitted_at) > Date.parse(filters.submitted_to) + ) { + return false; + } + + if (filters.direction && filters.direction !== 'BOTH' && filters.direction !== getTravelRuleDirection(record)) { + return false; + } + + if ( + filters.chain_id && + getTravelRulePrimaryChainId(record) !== filters.chain_id + ) { + return false; + } + + if ( + filters.currency && + data.interbank_settlement_amount?.currency !== filters.currency + ) { + return false; + } + + const tokenIdentification = getTravelRuleTokenIdentification(record); + if ( + filters.token_identifier && + tokenIdentification?.dti !== filters.token_identifier && + tokenIdentification?.isin !== filters.token_identifier && + tokenIdentification?.ticker !== filters.token_identifier + ) { + return false; + } + + if ( + filters.counterparty_vasp_lei && + buildTravelRuleSummary(record).creditor_vasp_lei !== filters.counterparty_vasp_lei && + buildTravelRuleSummary(record).debtor_vasp_lei !== filters.counterparty_vasp_lei + ) { + return false; + } + + if ( + filters.wallet_type && + buildTravelRuleSummary(record).debtor_wallet_type !== filters.wallet_type && + buildTravelRuleSummary(record).creditor_wallet_type !== filters.wallet_type + ) { + return false; + } + + if (filters.amount_min || filters.amount_max) { + const amount = Number.parseFloat(getTravelRuleData(record).interbank_settlement_amount?.amount ?? '0'); + if (filters.amount_min && amount < Number.parseFloat(filters.amount_min)) { + return false; + } + if (filters.amount_max && amount > Number.parseFloat(filters.amount_max)) { + return false; + } + } + + if ( + filters.debtor_wallet && + data.debtor_account?.proxy?.identification !== filters.debtor_wallet + ) { + return false; + } + + if ( + filters.creditor_wallet && + data.creditor_account?.proxy?.identification !== filters.creditor_wallet + ) { + return false; + } + + return true; + }); + + const sort = filters.sort ?? 'submitted_at_desc'; + filtered.sort((left, right) => { + if (sort === 'amount_asc' || sort === 'amount_desc') { + const leftAmount = Number.parseFloat( + getTravelRuleData(left).interbank_settlement_amount?.amount ?? '0', + ); + const rightAmount = Number.parseFloat( + getTravelRuleData(right).interbank_settlement_amount?.amount ?? '0', + ); + if (leftAmount !== rightAmount) { + return sort === 'amount_asc' + ? leftAmount - rightAmount + : rightAmount - leftAmount; + } + } else { + const leftSubmittedAt = Date.parse(left.submitted_at); + const rightSubmittedAt = Date.parse(right.submitted_at); + if (leftSubmittedAt !== rightSubmittedAt) { + return sort === 'submitted_at_asc' + ? leftSubmittedAt - rightSubmittedAt + : rightSubmittedAt - leftSubmittedAt; + } + } + + return left.record_id.localeCompare(right.record_id); + }); + + return filtered; + } + + searchTravelRuleResponse(filters = {}) { + const pageSize = Number.parseInt(filters.page_size ?? '50', 10) || 50; + const records = this.searchTravelRuleRecords(filters); + const offset = decodeCursor(filters.after); + const page = records.slice(offset, offset + pageSize); + const nextOffset = offset + page.length; + return { + total_matched: records.length, + page_size: page.length, + ...(nextOffset < records.length ? { next_cursor: encodeCursor(nextOffset) } : {}), + query_period: { + from: filters.submitted_from ?? null, + to: filters.submitted_to ?? null, + }, + generated_at: nowIso(), + records: page.map((record) => buildTravelRuleSummary(record)), + }; + } + + getTravelRuleStats(filters = {}) { + const records = this.searchTravelRuleRecords(filters); + const latestRejectedCount = records.filter( + (record) => getTravelRuleLatestCallbackStatus(record) === 'REJECTED', + ).length; + const pendingCount = records.filter( + (record) => getTravelRuleLatestCallbackStatus(record) === 'PENDING', + ).length; + const unhostedWalletCount = records.filter((record) => { + const summary = buildTravelRuleSummary(record); + return ( + summary.debtor_wallet_type === 'UNHOSTED' || + summary.creditor_wallet_type === 'UNHOSTED' + ); + }).length; + const groupBy = filters.group_by; + const breakdownMap = new Map(); + + if (groupBy) { + for (const record of records) { + const breakdownKey = getTravelRuleBreakdownKey(record, groupBy); + if (!breakdownKey) { + continue; + } + const existing = breakdownMap.get(breakdownKey.value) ?? { + dimension_value: breakdownKey.value, + dimension_label: breakdownKey.label, + records: [], + }; + existing.records.push(record); + breakdownMap.set(breakdownKey.value, existing); + } + } + + return { + period: { + from: filters.submitted_from, + to: filters.submitted_to, + }, + direction: filters.direction ?? 'BOTH', + generated_at: nowIso(), + totals: { + record_count: records.length, + volumes: buildTravelRuleCurrencyVolumeMap(records), + callback_pending_count: pendingCount, + rejection_count: latestRejectedCount, + rejection_rate_pct: + records.length === 0 + ? 0 + : Number(((latestRejectedCount / records.length) * 100).toFixed(2)), + unhosted_wallet_count: unhostedWalletCount, + }, + ...(groupBy + ? { + breakdown: Array.from(breakdownMap.values()).map((entry) => { + const rejectionCount = entry.records.filter( + (record) => getTravelRuleLatestCallbackStatus(record) === 'REJECTED', + ).length; + return { + dimension_value: entry.dimension_value, + dimension_label: entry.dimension_label, + record_count: entry.records.length, + volumes: buildTravelRuleCurrencyVolumeMap(entry.records), + rejection_count: rejectionCount, + rejection_rate_pct: + entry.records.length === 0 + ? 0 + : Number(((rejectionCount / entry.records.length) * 100).toFixed(2)), + }; + }), + } + : {}), + }; + } + + createQuote(request) { + const quote = this.chainAdapter.buildQuoteResponse(request); + if (isPromiseLike(quote)) { + throw new Error('createQuoteAsync must be used with an async chain adapter.'); + } + this.insertQuoteStmt.run( + quote.quote_id, + quote.valid_until, + quote.created_at, + serialize(request), + serialize(quote), + ); + + return quote; + } + + async createQuoteAsync(request) { + const quote = await this.chainAdapter.buildQuoteResponse(request); + this.insertQuoteStmt.run( + quote.quote_id, + quote.valid_until, + quote.created_at, + serialize(request), + serialize(quote), + ); + + return quote; + } + + getQuote(quoteId) { + const row = this.getQuoteStmt.get(quoteId); + return row ? parseJson(row.response_json, null) : null; + } + + listInstructions() { + return this.listInstructionsStmt + .all() + .map((row) => parseJson(row.instruction_json, null)) + .filter(Boolean) + .map((record) => this.advanceInstructionLifecycle(record, { persist: true })); + } + + async listInstructionsAsync() { + const records = this.listInstructionsStmt + .all() + .map((row) => parseJson(row.instruction_json, null)) + .filter(Boolean); + const advanced = []; + + for (const record of records) { + advanced.push(await this.advanceInstructionLifecycleAsync(record, { persist: true })); + } + + return advanced; + } + + findInstructionByEndToEndId(endToEndIdentification) { + return this.listInstructions().find( + (record) => + record.payment_identification?.end_to_end_identification === + endToEndIdentification, + ); + } + + async findInstructionByEndToEndIdAsync(endToEndIdentification) { + const instructions = await this.listInstructionsAsync(); + return instructions.find( + (record) => + record.payment_identification?.end_to_end_identification === + endToEndIdentification, + ); + } + + findInstructionByUetr(uetr) { + return this.listInstructions().find( + (record) => + record.uetr === uetr || record.payment_identification?.uetr === uetr, + ); + } + + async findInstructionByUetrAsync(uetr) { + const instructions = await this.listInstructionsAsync(); + return instructions.find( + (record) => + record.uetr === uetr || record.payment_identification?.uetr === uetr, + ); + } + + createInstruction(submission) { + const normalized = normalizeInstructionSubmission(submission); + const createdAt = nowIso(); + const initialStatus = this.chainAdapter.hasExpired(normalized.expiry_date_time) + ? 'EXPIRED' + : 'PENDING'; + const feeEstimate = normalized.payment_identification.quote_id + ? this.getQuote(normalized.payment_identification.quote_id)?.fee_estimate ?? + this.chainAdapter.buildFeeEstimate({ + amount: normalized.interbank_settlement_amount.amount, + ramp_type: normalized.blockchain_instruction?.ramp_instruction?.ramp_type, + }) + : this.chainAdapter.buildFeeEstimate({ + amount: normalized.interbank_settlement_amount.amount, + ramp_type: normalized.blockchain_instruction?.ramp_instruction?.ramp_type, + }); + + const record = { + instruction_id: randomUUID(), + uetr: normalized.payment_identification.uetr, + status: initialStatus, + custody_model: normalized.blockchain_instruction.custody_model, + debit_timing: 'ON_BROADCAST', + payment_identification: normalized.payment_identification, + settlement_information: normalized.settlement_information, + payment_type_information: normalized.payment_type_information, + debtor: normalized.debtor, + debtor_account: normalized.debtor_account, + debtor_agent: normalized.debtor_agent, + creditor: normalized.creditor, + creditor_account: normalized.creditor_account, + creditor_agent: normalized.creditor_agent, + charge_bearer: normalized.charge_bearer, + interbank_settlement_amount: normalized.interbank_settlement_amount, + instructed_amount: normalized.instructed_amount, + instruction_for_next_agent: normalized.instruction_for_next_agent, + purpose: normalized.purpose, + remittance_information: normalized.remittance_information, + blockchain_instruction: normalized.blockchain_instruction, + fee_estimate: feeEstimate, + on_chain_settlement: this.chainAdapter.normalizeOnChainSettlement( + null, + normalized.interbank_settlement_amount.amount, + normalized, + ), + travel_rule_record_id: normalized.travel_rule_record_id, + created_at: createdAt, + updated_at: createdAt, + expiry_date_time: normalized.expiry_date_time, + failure_reason: initialStatus === 'EXPIRED' + ? 'Instruction expired before execution.' + : null, + status_history: [ + buildInstructionStatusEvent({ + status: initialStatus, + statusAt: createdAt, + failureReason: + initialStatus === 'EXPIRED' + ? 'Instruction expired before execution.' + : null, + }), + ], + }; + + this.insertInstructionStmt.run( + record.instruction_id, + record.status, + record.created_at, + record.updated_at, + serialize(record), + ); + this.appendInstructionOutboxEvents(record, null); + this.appendReportingNotifications(record); + + return record; + } + + async createInstructionAsync(submission) { + const normalized = normalizeInstructionSubmission(submission); + const creationKey = + normalized.payment_identification?.end_to_end_identification ?? + normalized.payment_identification?.uetr ?? + null; + + if (!creationKey) { + return this.createInstructionFromNormalizedAsync(normalized); + } + + const inFlightCreation = this.inFlightInstructionCreations.get(creationKey); + if (inFlightCreation) { + return inFlightCreation; + } + + const creation = this.createInstructionFromNormalizedAsync(normalized).finally(() => { + this.inFlightInstructionCreations.delete(creationKey); + }); + this.inFlightInstructionCreations.set(creationKey, creation); + return creation; + } + + async createInstructionFromNormalizedAsync(normalized) { + const createdAt = nowIso(); + const initialStatus = this.chainAdapter.hasExpired(normalized.expiry_date_time) + ? 'EXPIRED' + : 'PENDING'; + const fallbackFeeRequest = { + amount: normalized.interbank_settlement_amount.amount, + ramp_type: normalized.blockchain_instruction?.ramp_instruction?.ramp_type, + }; + const feeEstimate = normalized.payment_identification.quote_id + ? this.getQuote(normalized.payment_identification.quote_id)?.fee_estimate ?? + (await this.chainAdapter.buildFeeEstimate(fallbackFeeRequest)) + : await this.chainAdapter.buildFeeEstimate(fallbackFeeRequest); + + const record = { + instruction_id: randomUUID(), + uetr: normalized.payment_identification.uetr, + status: initialStatus, + custody_model: normalized.blockchain_instruction.custody_model, + debit_timing: 'ON_BROADCAST', + payment_identification: normalized.payment_identification, + settlement_information: normalized.settlement_information, + payment_type_information: normalized.payment_type_information, + debtor: normalized.debtor, + debtor_account: normalized.debtor_account, + debtor_agent: normalized.debtor_agent, + creditor: normalized.creditor, + creditor_account: normalized.creditor_account, + creditor_agent: normalized.creditor_agent, + charge_bearer: normalized.charge_bearer, + interbank_settlement_amount: normalized.interbank_settlement_amount, + instructed_amount: normalized.instructed_amount, + instruction_for_next_agent: normalized.instruction_for_next_agent, + purpose: normalized.purpose, + remittance_information: normalized.remittance_information, + blockchain_instruction: normalized.blockchain_instruction, + fee_estimate: feeEstimate, + on_chain_settlement: await this.chainAdapter.normalizeOnChainSettlement( + null, + normalized.interbank_settlement_amount.amount, + normalized, + ), + travel_rule_record_id: normalized.travel_rule_record_id, + created_at: createdAt, + updated_at: createdAt, + expiry_date_time: normalized.expiry_date_time, + failure_reason: initialStatus === 'EXPIRED' + ? 'Instruction expired before execution.' + : null, + status_history: [ + buildInstructionStatusEvent({ + status: initialStatus, + statusAt: createdAt, + failureReason: + initialStatus === 'EXPIRED' + ? 'Instruction expired before execution.' + : null, + }), + ], + }; + + this.insertInstructionStmt.run( + record.instruction_id, + record.status, + record.created_at, + record.updated_at, + serialize(record), + ); + this.appendInstructionOutboxEvents(record, null); + this.appendReportingNotifications(record); + + if ( + typeof this.chainAdapter.submitLifecycleState === 'function' && + initialStatus === 'PENDING' + ) { + const lifecycleState = await this.chainAdapter.submitLifecycleState(record); + const submitted = buildInstructionLifecycleUpdate(record, lifecycleState); + if (serialize(submitted) !== serialize(record)) { + this.saveInstruction(submitted, { + previousRecord: record, + emitEvents: true, + emitNotifications: true, + }); + return submitted; + } + } + + return record; + } + + toInstructionResponse(record) { + return buildInstructionResponse(record, this.chainAdapter); + } + + toInstructionDetailResponse(record) { + return buildInstructionDetailResponse(record, this.chainAdapter); + } + + toExecutionStatusResponse(record) { + return buildExecutionStatusResponse(record, this.chainAdapter); + } + + toFinalityReceipt(record) { + return buildFinalityReceipt(record, this.chainAdapter); + } + + appendInstructionOutboxEvents(record, previousRecord = null) { + const events = []; + + if (shouldEmitExecutionStatusEvent(record, previousRecord)) { + const payload = this.toExecutionStatusResponse(record); + events.push( + buildOutboxEvent({ + eventType: 'execution_status.updated', + payloadSchema: 'execution_status', + resourcePath: `/execution-status/${record.instruction_id}`, + createdAt: payload.latest_status_at ?? record.updated_at ?? nowIso(), + record, + payload, + }), + ); + } + + if (shouldEmitFinalityReceiptEvent(record, previousRecord)) { + const payload = this.toFinalityReceipt(record); + events.push( + buildOutboxEvent({ + eventType: 'finality_receipt.updated', + payloadSchema: 'finality_receipt', + resourcePath: `/finality-receipt/${record.instruction_id}`, + createdAt: payload.observed_at ?? record.updated_at ?? nowIso(), + record, + payload, + }), + ); + } + + for (const event of events) { + this.insertOutboxEventStmt.run( + event.event_id, + event.event_type, + event.instruction_id, + event.created_at, + serialize(event), + ); + this.createWebhookDeliveriesForEvent(event); + } + + return events; + } + + getOutboxEvent(eventId) { + const row = this.getOutboxEventStmt.get(eventId); + return row ? parseJson(row.event_json, null) : null; + } + + listOutboxEvents(filters = {}) { + const pageSize = Number.parseInt(filters.page_size ?? '50', 10) || 50; + const offset = decodeCursor(filters.cursor); + const eventTypes = parseListFilter(filters.event_type); + const events = this.listOutboxEventsStmt + .all() + .map((row) => parseJson(row.event_json, null)) + .filter(Boolean) + .filter((event) => { + if (filters.instruction_id && event.instruction_id !== filters.instruction_id) { + return false; + } + if (filters.uetr && event.uetr !== filters.uetr) { + return false; + } + if (eventTypes.length && !eventTypes.includes(event.event_type)) { + return false; + } + if (filters.from && Date.parse(event.created_at) < Date.parse(filters.from)) { + return false; + } + if (filters.to && Date.parse(event.created_at) > Date.parse(filters.to)) { + return false; + } + return true; + }); + const page = events.slice(offset, offset + pageSize); + const nextOffset = offset + page.length; + + return { + total_matched: events.length, + page_size: page.length, + ...(nextOffset < events.length ? { next_cursor: encodeCursor(nextOffset) } : { next_cursor: null }), + generated_at: nowIso(), + events: page, + }; + } + + appendInvestigationCaseOutboxEvent(record) { + const event = buildOutboxEvent({ + eventType: 'investigation_case.updated', + payloadSchema: 'investigation_case', + resourcePath: `/exceptions/investigations/${record.investigation_case_id}`, + createdAt: record.updated_at, + instructionId: record.related_instruction_id, + uetr: record.related_uetr, + payload: record, + }); + this.insertOutboxEventStmt.run( + event.event_id, + event.event_type, + event.instruction_id, + event.created_at, + serialize(event), + ); + this.createWebhookDeliveriesForEvent(event); + return event; + } + + getInvestigationCase(caseId) { + const row = this.getInvestigationCaseStmt.get(caseId); + return row ? parseJson(row.case_json, null) : null; + } + + listInvestigationCaseRecords() { + return this.listInvestigationCasesStmt + .all() + .map((row) => parseJson(row.case_json, null)) + .filter(Boolean); + } + + filterInvestigationCaseRecords(filters = {}) { + const caseTypes = parseListFilter(filters.case_type); + const caseStatuses = parseListFilter(filters.case_status); + const priorities = parseListFilter(filters.priority); + const requiresCounterpartyAction = parseOptionalBoolean( + filters.requires_counterparty_action, + ); + + return this.listInvestigationCaseRecords().filter((record) => { + if ( + filters.related_instruction_id && + record.related_instruction_id !== filters.related_instruction_id + ) { + return false; + } + if (filters.related_uetr && record.related_uetr !== filters.related_uetr) { + return false; + } + if (caseTypes.length && !caseTypes.includes(record.case_type)) { + return false; + } + if (caseStatuses.length && !caseStatuses.includes(record.case_status)) { + return false; + } + if (priorities.length && !priorities.includes(record.priority)) { + return false; + } + if ( + requiresCounterpartyAction !== null && + record.requires_counterparty_action !== requiresCounterpartyAction + ) { + return false; + } + if ( + filters.opened_from && + Date.parse(record.opened_at) < Date.parse(filters.opened_from) + ) { + return false; + } + if ( + filters.opened_to && + Date.parse(record.opened_at) > Date.parse(filters.opened_to) + ) { + return false; + } + return true; + }); + } + + listInvestigationCases(filters = {}) { + const pageSize = Number.parseInt(filters.page_size ?? '50', 10) || 50; + const offset = decodeCursor(filters.cursor); + const records = this.filterInvestigationCaseRecords(filters); + const page = records.slice(offset, offset + pageSize); + const nextOffset = offset + page.length; + + return { + total_matched: records.length, + page_size: page.length, + ...(nextOffset < records.length ? { next_cursor: encodeCursor(nextOffset) } : { next_cursor: null }), + generated_at: nowIso(), + investigation_cases: page.map((record) => buildInvestigationCaseSummary(record)), + }; + } + + createInvestigationCase(submission, { instruction, linkedReturnCase = null } = {}) { + const record = buildInvestigationCaseRecord( + submission, + instruction, + linkedReturnCase, + ); + this.insertInvestigationCaseStmt.run( + record.investigation_case_id, + record.related_instruction_id, + record.opened_at, + record.updated_at, + serialize(record), + ); + this.appendInvestigationCaseOutboxEvent(record); + return record; + } + + updateInvestigationCase(caseId, patch, { linkedReturnCase = null } = {}) { + const current = this.getInvestigationCase(caseId); + if (!current) { + return null; + } + + const updatedAt = nowIso(); + const nextStatus = patch.case_status ?? current.case_status; + assertInvestigationCaseWorkflow(current, nextStatus, patch); + const linkedReturnCaseId = + linkedReturnCase?.return_case_id ?? + patch.linked_return_case_id ?? + current.linked_return_case_id ?? + null; + const affectedNotificationIds = + patch.affected_notification_ids !== undefined + ? normalizeExceptionReferenceIds(patch.affected_notification_ids) + : normalizeExceptionReferenceIds(current.affected_notification_ids); + const affectedStatementIds = + patch.affected_statement_ids !== undefined + ? normalizeExceptionReferenceIds(patch.affected_statement_ids) + : normalizeExceptionReferenceIds(current.affected_statement_ids); + const summary = + patch.resolution_summary ?? + (patch.case_status && patch.case_status !== current.case_status + ? `Investigation case moved to ${patch.case_status}.` + : 'Investigation case updated.'); + const updated = { + ...current, + case_status: nextStatus, + priority: patch.priority ?? current.priority, + requires_counterparty_action: + typeof patch.requires_counterparty_action === 'boolean' + ? patch.requires_counterparty_action + : current.requires_counterparty_action, + resolution_type: patch.resolution_type ?? current.resolution_type ?? null, + resolution_summary: + patch.resolution_summary ?? current.resolution_summary ?? null, + linked_return_case_id: linkedReturnCaseId, + current_owner: + patch.current_owner ?? current.current_owner ?? current.opened_by ?? null, + assigned_team: patch.assigned_team ?? current.assigned_team ?? null, + next_action_due_at: + patch.next_action_due_at ?? current.next_action_due_at ?? null, + counterparty_reference: + patch.counterparty_reference ?? current.counterparty_reference ?? null, + reporting_follow_up_required: + typeof patch.reporting_follow_up_required === 'boolean' + ? patch.reporting_follow_up_required + : current.reporting_follow_up_required === true, + affected_notification_ids: affectedNotificationIds, + affected_statement_ids: affectedStatementIds, + counterparty: patch.counterparty ?? current.counterparty ?? null, + narrative: patch.narrative ?? current.narrative, + updated_at: updatedAt, + traceability: buildExceptionTraceability({ + instructionId: current.related_instruction_id, + uetr: current.related_uetr, + endToEndIdentification: current.end_to_end_identification ?? null, + travelRuleRecordId: current.related_travel_rule_record_id ?? null, + transactionHash: current.related_transaction_hash ?? null, + investigationCaseId: current.investigation_case_id, + returnCaseId: linkedReturnCaseId, + }), + status_history: appendExceptionCaseActivity(current.status_history, { + status: nextStatus, + updatedAt, + summary, + }), + }; + + this.updateInvestigationCaseStmt.run( + updated.updated_at, + serialize(updated), + updated.investigation_case_id, + ); + this.appendInvestigationCaseOutboxEvent(updated); + return updated; + } + + appendReturnCaseOutboxEvent(record) { + const event = buildOutboxEvent({ + eventType: 'return_case.updated', + payloadSchema: 'return_case', + resourcePath: `/exceptions/returns/${record.return_case_id}`, + createdAt: record.updated_at, + instructionId: record.related_instruction_id, + uetr: record.related_uetr, + payload: record, + }); + this.insertOutboxEventStmt.run( + event.event_id, + event.event_type, + event.instruction_id, + event.created_at, + serialize(event), + ); + this.createWebhookDeliveriesForEvent(event); + return event; + } + + getReturnCaseRecord(returnCaseId) { + const row = this.getReturnCaseStmt.get(returnCaseId); + return row ? parseJson(row.case_json, null) : null; + } + + getReturnCase(returnCaseId) { + const record = this.getReturnCaseRecord(returnCaseId); + if (!record) { + return null; + } + if (record.exception_type === 'REVERSAL') { + return null; + } + return record; + } + + listReturnCaseRecords({ includeReversals = false } = {}) { + const records = this.listReturnCasesStmt + .all() + .map((row) => parseJson(row.case_json, null)) + .filter(Boolean); + if (includeReversals) { + return records; + } + return records.filter((record) => record.exception_type !== 'REVERSAL'); + } + + filterReturnCaseRecords(filters = {}) { + const returnTypes = parseListFilter(filters.return_type); + const returnMethods = parseListFilter(filters.return_method); + const returnStatuses = parseListFilter(filters.return_status); + + return this.listReturnCaseRecords().filter((record) => { + if ( + filters.original_instruction_id && + record.original_instruction_id !== filters.original_instruction_id + ) { + return false; + } + if (filters.original_uetr && record.original_uetr !== filters.original_uetr) { + return false; + } + if ( + filters.linked_investigation_case_id && + record.linked_investigation_case_id !== filters.linked_investigation_case_id + ) { + return false; + } + if (returnTypes.length && !returnTypes.includes(record.return_type)) { + return false; + } + if (returnMethods.length && !returnMethods.includes(record.return_method)) { + return false; + } + if (returnStatuses.length && !returnStatuses.includes(record.return_status)) { + return false; + } + if ( + filters.opened_from && + Date.parse(record.opened_at) < Date.parse(filters.opened_from) + ) { + return false; + } + if ( + filters.opened_to && + Date.parse(record.opened_at) > Date.parse(filters.opened_to) + ) { + return false; + } + return true; + }); + } + + listReturnCases(filters = {}) { + const pageSize = Number.parseInt(filters.page_size ?? '50', 10) || 50; + const offset = decodeCursor(filters.cursor); + const records = this.filterReturnCaseRecords(filters); + const page = records.slice(offset, offset + pageSize); + const nextOffset = offset + page.length; + + return { + total_matched: records.length, + page_size: page.length, + ...(nextOffset < records.length ? { next_cursor: encodeCursor(nextOffset) } : { next_cursor: null }), + generated_at: nowIso(), + return_cases: page.map((record) => buildReturnCaseSummary(record)), + }; + } + + createReturnCase(submission, { instruction, linkedInvestigationCase = null } = {}) { + const record = buildReturnCaseRecord( + submission, + instruction, + linkedInvestigationCase, + ); + this.insertReturnCaseStmt.run( + record.return_case_id, + record.related_instruction_id, + record.opened_at, + record.updated_at, + serialize(record), + ); + this.appendReturnCaseOutboxEvent(record); + return record; + } + + updateReturnCase(returnCaseId, patch, { linkedInvestigationCase = null } = {}) { + const current = this.getReturnCase(returnCaseId); + if (!current) { + return null; + } + + const updatedAt = nowIso(); + const nextStatus = patch.return_status ?? current.return_status; + assertReturnCaseWorkflow(current, nextStatus, patch); + const linkedInvestigationCaseId = + linkedInvestigationCase?.investigation_case_id ?? + patch.linked_investigation_case_id ?? + current.linked_investigation_case_id ?? + null; + const affectedNotificationIds = + patch.affected_notification_ids !== undefined + ? normalizeExceptionReferenceIds(patch.affected_notification_ids) + : normalizeExceptionReferenceIds(current.affected_notification_ids); + const affectedStatementIds = + patch.affected_statement_ids !== undefined + ? normalizeExceptionReferenceIds(patch.affected_statement_ids) + : normalizeExceptionReferenceIds(current.affected_statement_ids); + const summary = + patch.resolution_summary ?? + (patch.return_status && patch.return_status !== current.return_status + ? `Return case moved to ${patch.return_status}.` + : 'Return case updated.'); + const updated = { + ...current, + return_status: nextStatus, + resolution_summary: + patch.resolution_summary ?? current.resolution_summary ?? null, + linked_investigation_case_id: linkedInvestigationCaseId, + compensating_instruction_id: + patch.compensating_instruction_id ?? + current.compensating_instruction_id ?? + null, + off_chain_reference: + patch.off_chain_reference ?? current.off_chain_reference ?? null, + current_owner: + patch.current_owner ?? current.current_owner ?? current.opened_by ?? null, + assigned_team: patch.assigned_team ?? current.assigned_team ?? null, + next_action_due_at: + patch.next_action_due_at ?? current.next_action_due_at ?? null, + counterparty_reference: + patch.counterparty_reference ?? current.counterparty_reference ?? null, + reporting_follow_up_required: + typeof patch.reporting_follow_up_required === 'boolean' + ? patch.reporting_follow_up_required + : current.reporting_follow_up_required === true, + affected_notification_ids: affectedNotificationIds, + affected_statement_ids: affectedStatementIds, + counterparty: patch.counterparty ?? current.counterparty ?? null, + narrative: patch.narrative ?? current.narrative, + updated_at: updatedAt, + settled_at: + nextStatus === 'SETTLED' + ? current.settled_at ?? updatedAt + : current.settled_at ?? null, + traceability: buildExceptionTraceability({ + instructionId: current.related_instruction_id, + uetr: current.related_uetr, + endToEndIdentification: current.end_to_end_identification ?? null, + travelRuleRecordId: current.related_travel_rule_record_id ?? null, + transactionHash: current.related_transaction_hash ?? null, + investigationCaseId: linkedInvestigationCaseId, + returnCaseId: current.return_case_id, + }), + status_history: appendExceptionCaseActivity(current.status_history, { + status: nextStatus, + updatedAt, + summary, + }), + }; + + this.updateReturnCaseStmt.run( + updated.updated_at, + serialize(updated), + updated.return_case_id, + ); + this.appendReturnCaseOutboxEvent(updated); + return updated; + } + + findActiveTomCaseForInstruction(instructionId) { + if (!instructionId) { + return null; + } + const records = this.listReturnCaseRecords({ includeReversals: true }) + .filter((record) => record.original_instruction_id === instructionId); + for (const record of records) { + if (record.exception_type === 'RETURN' && record.origin === 'TOM' && isTomReturnCaseBlocking(record)) { + return record; + } + if (record.exception_type === 'REVERSAL' && isTomReversalCaseBlocking(record)) { + return record; + } + } + return null; + } + + getLatestReversalForInstruction(instructionId) { + if (!instructionId) { + return null; + } + const records = this.listReturnCaseRecords({ includeReversals: true }) + .filter( + (record) => + record.exception_type === 'REVERSAL' && + record.original_instruction_id === instructionId, + ) + .sort( + (a, b) => + Date.parse(b.requested_at ?? b.opened_at) - + Date.parse(a.requested_at ?? a.opened_at), + ); + return records[0] ?? null; + } + + async createTomCompensatingInstructionAsync(originalInstruction, returnedAmount) { + const createdAt = nowIso(); + const compensatingId = randomUUID(); + const compensatingUetr = randomUUID(); + const interbankAmount = { + amount: returnedAmount.amount, + currency: returnedAmount.currency, + }; + const fallbackFeeRequest = { + amount: interbankAmount.amount, + ramp_type: originalInstruction.blockchain_instruction?.ramp_instruction?.ramp_type, + }; + const feeEstimate = await this.chainAdapter.buildFeeEstimate(fallbackFeeRequest); + const onChainSettlement = await this.chainAdapter.normalizeOnChainSettlement( + null, + interbankAmount.amount, + originalInstruction, + ); + const originalE2e = + originalInstruction.payment_identification?.end_to_end_identification ?? null; + const record = { + instruction_id: compensatingId, + uetr: compensatingUetr, + status: 'PENDING', + custody_model: originalInstruction.custody_model, + debit_timing: originalInstruction.debit_timing ?? 'ON_BROADCAST', + payment_identification: { + end_to_end_identification: `RETURN-${compensatingId}`, + uetr: compensatingUetr, + compensates_end_to_end_identification: originalE2e, + }, + settlement_information: originalInstruction.settlement_information, + payment_type_information: originalInstruction.payment_type_information, + debtor: originalInstruction.creditor, + debtor_account: originalInstruction.creditor_account, + debtor_agent: originalInstruction.creditor_agent, + creditor: originalInstruction.debtor, + creditor_account: originalInstruction.debtor_account, + creditor_agent: originalInstruction.debtor_agent, + charge_bearer: originalInstruction.charge_bearer, + interbank_settlement_amount: interbankAmount, + instructed_amount: interbankAmount, + instruction_for_next_agent: null, + purpose: originalInstruction.purpose, + remittance_information: originalInstruction.remittance_information, + blockchain_instruction: originalInstruction.blockchain_instruction, + fee_estimate: feeEstimate, + on_chain_settlement: onChainSettlement, + travel_rule_record_id: null, + created_at: createdAt, + updated_at: createdAt, + expiry_date_time: null, + failure_reason: null, + compensates_instruction_id: originalInstruction.instruction_id, + origin: 'TOM_RETURN', + status_history: [ + buildInstructionStatusEvent({ + status: 'PENDING', + statusAt: createdAt, + }), + ], + }; + + this.insertInstructionStmt.run( + record.instruction_id, + record.status, + record.created_at, + record.updated_at, + serialize(record), + ); + this.appendInstructionOutboxEvents(record, null); + return record; + } + + async createTomReturnAsync(originalInstruction, body) { + const compensatingInstruction = await this.createTomCompensatingInstructionAsync( + originalInstruction, + body.returned_amount, + ); + const record = buildTomReturnCaseRecord({ + instruction: originalInstruction, + body, + compensatingInstruction, + }); + this.insertReturnCaseStmt.run( + record.return_case_id, + record.related_instruction_id, + record.opened_at, + record.updated_at, + serialize(record), + ); + this.appendReturnCaseOutboxEvent(record); + return { record, compensatingInstruction }; + } + + createTomReversalRequest(originalInstruction, body) { + const record = buildTomReversalCaseRecord({ + instruction: originalInstruction, + body, + }); + this.insertReturnCaseStmt.run( + record.return_case_id, + record.related_instruction_id, + record.opened_at, + record.updated_at, + serialize(record), + ); + return record; + } + + toTomReturnResponse(record) { + return buildTomReturnResponse(record); + } + + toTomReversalResponse(record) { + return buildTomReversalResponse(record); + } + + toTomReversalStatusResponse(record) { + return buildTomReversalStatusResponse(record); + } + + createWebhookSubscription(submission) { + const record = buildWebhookSubscriptionRecord(submission); + this.insertWebhookSubscriptionStmt.run( + record.subscription_id, + record.url, + record.active ? 1 : 0, + record.updated_at, + serialize(record), + ); + + return buildWebhookSubscriptionResponse(record, { includeSecret: true }); + } + + getWebhookSubscriptionRecord(subscriptionId) { + const row = this.getWebhookSubscriptionStmt.get(subscriptionId); + return row ? parseJson(row.subscription_json, null) : null; + } + + getWebhookSubscription(subscriptionId) { + const record = this.getWebhookSubscriptionRecord(subscriptionId); + return record ? buildWebhookSubscriptionResponse(record) : null; + } + + listWebhookSubscriptionRecords() { + return this.listWebhookSubscriptionsStmt + .all() + .map((row) => parseJson(row.subscription_json, null)) + .filter(Boolean); + } + + listWebhookSubscriptions() { + return { + total_matched: this.listWebhookSubscriptionRecords().length, + generated_at: nowIso(), + subscriptions: this.listWebhookSubscriptionRecords().map((record) => + buildWebhookSubscriptionResponse(record), + ), + }; + } + + findReportNotificationSubscription({ + url, + walletAddress, + chainDli, + }) { + return this.listWebhookSubscriptionRecords().find( + (subscription) => + subscription.active && + subscription.subscription_kind === 'REPORT_NOTIFICATION' && + subscription.url === url && + subscription.filter_wallet_address === walletAddress && + subscription.filter_chain_dli === chainDli, + ) ?? null; + } + + cancelWebhookSubscription(subscriptionId) { + const current = this.getWebhookSubscriptionRecord(subscriptionId); + if (!current) { + return null; + } + + return this.deactivateWebhookSubscriptionRecord(current); + } + + deactivateWebhookSubscriptionRecord(subscription, updatedAt = nowIso()) { + if (!subscription) { + return null; + } + + const updated = { + ...subscription, + active: false, + updated_at: updatedAt, + }; + this.updateWebhookSubscriptionStmt.run( + updated.url, + updated.active ? 1 : 0, + updated.updated_at, + serialize(updated), + updated.subscription_id, + ); + + return buildWebhookSubscriptionResponse(updated); + } + + createReportNotificationSubscription({ + callbackUrl, + walletAddress, + chainDli, + queryIdentification = null, + }) { + const existing = this.findReportNotificationSubscription({ + url: callbackUrl, + walletAddress, + chainDli, + }); + if (existing) { + return { + duplicate: true, + subscription: buildWebhookSubscriptionResponse(existing), + }; + } + + const subscription = this.createWebhookSubscription({ + url: callbackUrl, + signing_secret: randomUUID().replaceAll('-', ''), + subscribed_event_types: ['reporting_notification.created'], + description: `Spec 3 notification subscription for ${walletAddress} on ${chainDli}`, + filter_wallet_address: walletAddress, + filter_chain_dli: chainDli, + subscription_kind: 'REPORT_NOTIFICATION', + query_identification: queryIdentification, + }); + + return { + duplicate: false, + subscription, + }; + } + + createReportStatementCallback({ + callbackUrl, + queryIdentification, + walletAddress, + chainDli, + statement, + }) { + const statementInstructionId = + statement?.statement?.entries?.[0]?.blockchain_detail?.instruction_id ?? + queryIdentification; + const subscription = this.createWebhookSubscription({ + url: callbackUrl, + signing_secret: randomUUID().replaceAll('-', ''), + subscribed_event_types: ['reporting_statement.ready'], + description: `Spec 3 statement callback for ${walletAddress} on ${chainDli} (${queryIdentification})`, + filter_wallet_address: walletAddress, + filter_chain_dli: chainDli, + subscription_kind: 'REPORT_STATEMENT_CALLBACK', + query_identification: queryIdentification, + max_attempts: 3, + }); + const event = buildOutboxEvent({ + eventType: 'reporting_statement.ready', + payloadSchema: 'wallet_statement', + resourcePath: `/report/statement?wallet_address=${encodeURIComponent( + walletAddress, + )}&chain_dli=${encodeURIComponent(chainDli)}`, + createdAt: nowIso(), + instructionId: statementInstructionId, + uetr: null, + payload: { + query_identification: queryIdentification, + wallet_address: walletAddress, + chain_dli: chainDli, + statement, + }, + }); + + this.insertOutboxEventStmt.run( + event.event_id, + event.event_type, + event.instruction_id, + event.created_at, + serialize(event), + ); + this.createWebhookDeliveriesForEvent(event); + + return { + subscription_id: subscription.subscription_id, + event_id: event.event_id, + }; + } + + cancelReportNotificationSubscription(subscriptionId) { + const subscription = this.getWebhookSubscriptionRecord(subscriptionId); + if (!subscription || subscription.subscription_kind !== 'REPORT_NOTIFICATION') { + return null; + } + + return this.cancelWebhookSubscription(subscriptionId); + } + + createWebhookDeliveriesForEvent(event) { + const subscriptions = this.listWebhookSubscriptionRecords().filter( + (subscription) => doesWebhookSubscriptionMatchEvent(subscription, event), + ); + + const deliveries = []; + for (const subscription of subscriptions) { + const delivery = buildWebhookDeliveryRecord(event, subscription); + this.insertWebhookDeliveryStmt.run( + delivery.delivery_id, + delivery.subscription_id, + delivery.event_id, + delivery.event_type, + delivery.instruction_id, + delivery.delivery_state, + delivery.next_attempt_at, + delivery.updated_at, + serialize(delivery), + ); + deliveries.push(delivery); + } + + return deliveries; + } + + getWebhookDelivery(deliveryId) { + const row = this.getWebhookDeliveryStmt.get(deliveryId); + return row ? parseJson(row.delivery_json, null) : null; + } + + listWebhookDeliveryRecords(filters = {}) { + const eventTypes = parseListFilter(filters.event_type); + const deliveryStates = parseListFilter(filters.delivery_state); + const deadLettered = parseOptionalBoolean(filters.dead_lettered); + + return this.listWebhookDeliveriesStmt + .all() + .map((row) => parseJson(row.delivery_json, null)) + .filter(Boolean) + .filter((delivery) => { + if ( + filters.subscription_id && + delivery.subscription_id !== filters.subscription_id + ) { + return false; + } + if (filters.instruction_id && delivery.instruction_id !== filters.instruction_id) { + return false; + } + if (filters.uetr && delivery.uetr !== filters.uetr) { + return false; + } + if (eventTypes.length && !eventTypes.includes(delivery.event_type)) { + return false; + } + if (deliveryStates.length && !deliveryStates.includes(delivery.delivery_state)) { + return false; + } + if (deadLettered === true && !delivery.dead_lettered_at) { + return false; + } + if (deadLettered === false && delivery.dead_lettered_at) { + return false; + } + return true; + }); + } + + paginateWebhookDeliveries(deliveries, filters = {}) { + const pageSize = Number.parseInt(filters.page_size ?? '50', 10) || 50; + const offset = decodeCursor(filters.cursor); + const page = deliveries.slice(offset, offset + pageSize); + const nextOffset = offset + page.length; + + return { + total_matched: deliveries.length, + page_size: page.length, + ...(nextOffset < deliveries.length ? { next_cursor: encodeCursor(nextOffset) } : { next_cursor: null }), + generated_at: nowIso(), + deliveries: page, + }; + } + + listWebhookDeliveries(filters = {}) { + return this.paginateWebhookDeliveries( + this.listWebhookDeliveryRecords(filters), + filters, + ); + } + + listDeadLetterWebhookDeliveries(filters = {}) { + return this.paginateWebhookDeliveries( + this.listWebhookDeliveryRecords({ + ...filters, + dead_lettered: true, + }), + filters, + ); + } + + getWebhookDeliveryStats(filters = {}) { + const deliveries = this.listWebhookDeliveryRecords(filters); + const dueNow = Date.now(); + const stateCounts = { + PENDING: 0, + RETRYING: 0, + DELIVERED: 0, + FAILED: 0, + }; + + let dueNowCount = 0; + let oldestNextAttemptAt = null; + let latestDeadLetterAt = null; + + for (const delivery of deliveries) { + if (stateCounts[delivery.delivery_state] !== undefined) { + stateCounts[delivery.delivery_state] += 1; + } + + if ( + !WEBHOOK_DELIVERY_TERMINAL_STATES.has(delivery.delivery_state) && + Date.parse(delivery.next_attempt_at) <= dueNow + ) { + dueNowCount += 1; + } + + if ( + !WEBHOOK_DELIVERY_TERMINAL_STATES.has(delivery.delivery_state) && + (!oldestNextAttemptAt || + Date.parse(delivery.next_attempt_at) < Date.parse(oldestNextAttemptAt)) + ) { + oldestNextAttemptAt = delivery.next_attempt_at; + } + + if ( + delivery.dead_lettered_at && + (!latestDeadLetterAt || + Date.parse(delivery.dead_lettered_at) > Date.parse(latestDeadLetterAt)) + ) { + latestDeadLetterAt = delivery.dead_lettered_at; + } + } + + return { + generated_at: nowIso(), + delivery_guarantee: WEBHOOK_DELIVERY_GUARANTEE, + retry_schedule_ms: [...this.webhookRetryScheduleMs], + filters_applied: { + subscription_id: filters.subscription_id ?? null, + instruction_id: filters.instruction_id ?? null, + uetr: filters.uetr ?? null, + event_type: parseListFilter(filters.event_type), + }, + total_deliveries: deliveries.length, + state_counts: stateCounts, + dead_letter_count: deliveries.filter((delivery) => delivery.dead_lettered_at).length, + due_now_count: dueNowCount, + oldest_next_attempt_at: oldestNextAttemptAt, + latest_dead_letter_at: latestDeadLetterAt, + }; + } + + async dispatchPendingWebhookDeliveries({ + sender, + limit = 50, + subscriptionId = null, + } = {}) { + const dueNow = nowIso(); + const candidates = this.listWebhookDeliveriesStmt + .all() + .map((row) => parseJson(row.delivery_json, null)) + .filter(Boolean) + .filter((delivery) => { + if (subscriptionId && delivery.subscription_id !== subscriptionId) { + return false; + } + if (WEBHOOK_DELIVERY_TERMINAL_STATES.has(delivery.delivery_state)) { + return false; + } + return Date.parse(delivery.next_attempt_at) <= Date.parse(dueNow); + }) + .slice(0, limit); + + const results = []; + for (const delivery of candidates) { + const subscription = this.getWebhookSubscriptionRecord(delivery.subscription_id); + const event = this.getOutboxEvent(delivery.event_id); + + if (!subscription || !subscription.active || !event) { + const failedAt = nowIso(); + const terminalReason = + !subscription || !subscription.active + ? 'SUBSCRIPTION_INACTIVE' + : 'EVENT_MISSING'; + const skipped = { + ...delivery, + delivery_state: 'FAILED', + last_error: + terminalReason === 'EVENT_MISSING' + ? 'Referenced outbox event is missing.' + : 'Subscription is inactive or missing.', + failure_category: 'CONFIGURATION', + terminal_reason: terminalReason, + dead_lettered_at: failedAt, + updated_at: failedAt, + next_attempt_at: failedAt, + }; + this.updateWebhookDeliveryStmt.run( + skipped.delivery_state, + skipped.next_attempt_at, + skipped.updated_at, + serialize(skipped), + skipped.delivery_id, + ); + results.push(skipped); + continue; + } + + const attemptAt = nowIso(); + const deliveryPayload = buildWebhookDeliveryPayload({ + event, + delivery, + subscription, + reportingNotificationPayload: + subscription.subscription_kind === 'REPORT_NOTIFICATION' && + event.event_type === 'reporting_notification.created' + ? this.getSpecReportingNotification( + event.payload?.notification_id ?? null, + ) + : null, + }); + const body = serialize(deliveryPayload.body_object); + const timestamp = String(Math.floor(Date.parse(attemptAt) / 1000)); + const signature = buildWebhookSignature(subscription.signing_secret, timestamp, body); + + try { + const response = await sender({ + url: subscription.url, + headers: { + 'content-type': 'application/json', + 'x-pacscrypto-event-id': event.event_id, + 'x-pacscrypto-delivery-id': delivery.delivery_id, + 'x-pacscrypto-event-type': event.event_type, + 'x-pacscrypto-payload-mode': deliveryPayload.payload_mode, + 'x-pacscrypto-signature': signature, + 'x-pacscrypto-signature-timestamp': timestamp, + }, + body, + }); + const attemptCount = delivery.attempt_count + 1; + const delivered = response.status >= 200 && response.status < 300; + const updated = { + ...delivery, + attempt_count: attemptCount, + delivery_state: delivered + ? 'DELIVERED' + : attemptCount >= subscription.max_attempts + ? 'FAILED' + : 'RETRYING', + last_attempt_at: attemptAt, + next_attempt_at: delivered + ? attemptAt + : attemptCount >= subscription.max_attempts + ? attemptAt + : buildNextAttemptAt( + attemptAt, + attemptCount, + this.webhookRetryScheduleMs, + ), + response_status: response.status, + response_body_excerpt: response.bodyText?.slice(0, 500) ?? null, + last_error: delivered ? null : `Endpoint returned HTTP ${response.status}.`, + last_signature: signature, + failure_category: delivered ? null : 'HTTP_RESPONSE', + terminal_reason: + delivered || attemptCount < subscription.max_attempts + ? null + : 'MAX_ATTEMPTS_EXHAUSTED', + dead_lettered_at: + delivered || attemptCount < subscription.max_attempts ? null : attemptAt, + updated_at: attemptAt, + }; + + this.updateWebhookDeliveryStmt.run( + updated.delivery_state, + updated.next_attempt_at, + updated.updated_at, + serialize(updated), + updated.delivery_id, + ); + + if (delivered) { + const subscriptionUpdated = { + ...subscription, + last_delivery_at: attemptAt, + updated_at: attemptAt, + }; + this.updateWebhookSubscriptionStmt.run( + subscriptionUpdated.url, + subscriptionUpdated.active ? 1 : 0, + subscriptionUpdated.updated_at, + serialize(subscriptionUpdated), + subscriptionUpdated.subscription_id, + ); + if (subscription.subscription_kind === 'REPORT_STATEMENT_CALLBACK') { + this.deactivateWebhookSubscriptionRecord( + subscriptionUpdated, + attemptAt, + ); + } + } else if ( + attemptCount >= subscription.max_attempts && + subscription.subscription_kind === 'REPORT_STATEMENT_CALLBACK' + ) { + this.deactivateWebhookSubscriptionRecord(subscription, attemptAt); + } + + results.push(updated); + } catch (error) { + const attemptCount = delivery.attempt_count + 1; + const updated = { + ...delivery, + attempt_count: attemptCount, + delivery_state: + attemptCount >= subscription.max_attempts ? 'FAILED' : 'RETRYING', + last_attempt_at: attemptAt, + next_attempt_at: + attemptCount >= subscription.max_attempts + ? attemptAt + : buildNextAttemptAt( + attemptAt, + attemptCount, + this.webhookRetryScheduleMs, + ), + response_status: null, + response_body_excerpt: null, + last_error: error.message, + last_signature: signature, + failure_category: 'TRANSPORT', + terminal_reason: + attemptCount >= subscription.max_attempts + ? 'MAX_ATTEMPTS_EXHAUSTED' + : null, + dead_lettered_at: + attemptCount >= subscription.max_attempts ? attemptAt : null, + updated_at: attemptAt, + }; + this.updateWebhookDeliveryStmt.run( + updated.delivery_state, + updated.next_attempt_at, + updated.updated_at, + serialize(updated), + updated.delivery_id, + ); + if ( + attemptCount >= subscription.max_attempts && + subscription.subscription_kind === 'REPORT_STATEMENT_CALLBACK' + ) { + this.deactivateWebhookSubscriptionRecord(subscription, attemptAt); + } + results.push(updated); + } + } + + return { + dispatched_count: results.length, + generated_at: nowIso(), + deliveries: results, + }; + } + + getReportingNotification(notificationId) { + const row = this.getReportingNotificationStmt.get(notificationId); + return row ? parseJson(row.notification_json, null) : null; + } + + listReportingNotificationRecords() { + return this.listReportingNotificationsStmt + .all() + .map((row) => parseJson(row.notification_json, null)) + .filter(Boolean); + } + + listReportingNotificationsForInstruction(instructionId) { + return this.listReportingNotificationRecords().filter( + (record) => record.instruction_id === instructionId, + ); + } + + appendReportingNotificationOutboxEvents(notifications) { + const events = []; + for (const notification of notifications) { + const event = buildOutboxEvent({ + eventType: 'reporting_notification.created', + payloadSchema: 'reporting_notification', + resourcePath: `/reporting/notifications/${notification.notification_id}`, + createdAt: notification.created_at, + instructionId: notification.instruction_id, + uetr: notification.uetr, + payload: notification, + }); + this.insertOutboxEventStmt.run( + event.event_id, + event.event_type, + event.instruction_id, + event.created_at, + serialize(event), + ); + this.createWebhookDeliveriesForEvent(event); + events.push(event); + } + + return events; + } + + appendReportingNotifications(record) { + const existing = this.listReportingNotificationsForInstruction(record.instruction_id); + const hasDebtorDebit = existing.some( + (notification) => + notification.account_role === 'DEBTOR' && + notification.entry_type === 'DEBIT', + ); + const hasCreditorCredit = existing.some( + (notification) => + notification.account_role === 'CREDITOR' && + notification.entry_type === 'CREDIT', + ); + const created = []; + const debitTriggerStatus = getDebitNotificationTriggerStatus(record.debit_timing); + const upgraded = []; + + if (!hasDebtorDebit && hasInstructionReachedStatus(record, debitTriggerStatus)) { + const notification = buildReportingNotificationRecord( + record, + 'DEBTOR_DEBIT', + this.chainAdapter, + ); + this.insertReportingNotificationStmt.run( + notification.notification_id, + notification.instruction_id, + notification.booking_date_time, + serialize(notification), + ); + created.push(notification); + } + + if (record.status === 'FINAL') { + for (const notification of existing) { + if ( + notification.account_role !== 'DEBTOR' || + notification.entry_type !== 'DEBIT' || + deriveReportingEntryLifecycle(notification).entryStatus === 'BOOK' + ) { + continue; + } + + const upgradedNotification = upgradeReportingNotificationToFinal( + notification, + record, + this.chainAdapter, + ); + this.updateReportingNotificationStmt.run( + serialize(upgradedNotification), + upgradedNotification.notification_id, + ); + upgraded.push(upgradedNotification); + } + } + + if (!hasCreditorCredit && record.status === 'FINAL') { + const notification = buildReportingNotificationRecord( + record, + 'CREDITOR_CREDIT', + this.chainAdapter, + ); + this.insertReportingNotificationStmt.run( + notification.notification_id, + notification.instruction_id, + notification.booking_date_time, + serialize(notification), + ); + created.push(notification); + } + + this.appendReportingNotificationOutboxEvents(created); + this.refreshReportingStatements(record); + return [...created, ...upgraded]; + } + + refreshReportingStatements(record) { + const notifications = this.listReportingNotificationsForInstruction(record.instruction_id); + const notificationGroups = new Map(); + + for (const notification of notifications) { + const key = notification.account_role; + const existing = notificationGroups.get(key) ?? []; + existing.push(notification); + notificationGroups.set(key, existing); + } + + for (const [accountRole, groupedNotifications] of notificationGroups.entries()) { + const statementKey = buildReportingStatementKey(record, accountRole); + const existing = this.getReportingStatementByKey(statementKey); + const statement = buildReportingStatementRecord( + record, + groupedNotifications, + accountRole, + existing?.statement_id, + ); + if (!statement) { + continue; + } + + const persistedStatement = existing + ? { + ...statement, + created_at: existing.created_at ?? statement.created_at, + } + : statement; + + if (existing) { + this.updateReportingStatementStmt.run( + persistedStatement.statement_date, + persistedStatement.updated_at, + serialize(persistedStatement), + persistedStatement.statement_key, + ); + } else { + this.insertReportingStatementStmt.run( + persistedStatement.statement_id, + persistedStatement.statement_key, + persistedStatement.instruction_id, + persistedStatement.account_role, + persistedStatement.statement_date, + persistedStatement.updated_at, + serialize(persistedStatement), + ); + } + } + } + + filterReportingNotificationRecords(filters = {}) { + const entryTypes = parseListFilter(filters.entry_type); + const accountRoles = parseListFilter(filters.account_role); + const entryStatuses = parseListFilter(filters.entry_status); + return this.listReportingNotificationRecords().filter((record) => { + if (filters.instruction_id && record.instruction_id !== filters.instruction_id) { + return false; + } + if (filters.uetr && record.uetr !== filters.uetr) { + return false; + } + if (entryTypes.length && !entryTypes.includes(record.entry_type)) { + return false; + } + if (accountRoles.length && !accountRoles.includes(record.account_role)) { + return false; + } + if ( + entryStatuses.length && + !entryStatuses.includes(deriveReportingEntryLifecycle(record).entryStatus) + ) { + return false; + } + if (filters.chain_dli && record.chain_dli !== filters.chain_dli) { + return false; + } + if ( + filters.token_dti && + record.token?.token_dti !== filters.token_dti + ) { + return false; + } + if ( + filters.wallet_address && + record.party?.wallet_address !== filters.wallet_address + ) { + return false; + } + if ( + filters.counterparty_wallet && + record.counterparty?.wallet_address !== filters.counterparty_wallet + ) { + return false; + } + if ( + filters.transaction_hash && + record.transaction_hash !== filters.transaction_hash + ) { + return false; + } + if ( + filters.travel_rule_record_id && + record.travel_rule_record_id !== filters.travel_rule_record_id + ) { + return false; + } + if (filters.finality_status) { + const finalityStatus = buildReportEntrySummary(record).finality_status; + if (finalityStatus !== filters.finality_status) { + return false; + } + } + if (filters.amount_min) { + const amount = Number.parseFloat(record.settlement_amount?.amount ?? '0'); + if (amount < Number.parseFloat(filters.amount_min)) { + return false; + } + } + if (filters.amount_max) { + const amount = Number.parseFloat(record.settlement_amount?.amount ?? '0'); + if (amount > Number.parseFloat(filters.amount_max)) { + return false; + } + } + if ( + filters.booked_from && + Date.parse(record.booking_date_time) < Date.parse(filters.booked_from) + ) { + return false; + } + if ( + filters.booked_to && + Date.parse(record.booking_date_time) > Date.parse(filters.booked_to) + ) { + return false; + } + return true; + }); + } + + listReportingNotifications(filters = {}) { + const pageSize = Number.parseInt(filters.page_size ?? '50', 10) || 50; + const offset = decodeCursor(filters.cursor); + const records = sortReportingNotificationRecords( + this.filterReportingNotificationRecords(filters), + filters.sort, + ); + const page = records.slice(offset, offset + pageSize); + const nextOffset = offset + page.length; + + return { + total_matched: records.length, + page_size: page.length, + ...(nextOffset < records.length ? { next_cursor: encodeCursor(nextOffset) } : { next_cursor: null }), + generated_at: nowIso(), + notifications: page.map((record) => buildReportingNotificationSummary(record)), + }; + } + + getReportBalanceResponse(filters = {}) { + const records = sortReportingNotificationRecords( + this.filterReportingNotificationRecords(filters), + filters.sort, + ); + + return buildReportBalanceSnapshots(records); + } + + buildReportingInstructionLookup(records = []) { + const instructionIds = Array.from( + new Set(records.map((record) => record.instruction_id).filter(Boolean)), + ); + const lookup = new Map(); + + for (const instructionId of instructionIds) { + lookup.set(instructionId, this.getInstruction(instructionId)); + } + + return lookup; + } + + getSpecReportingNotification(notificationId) { + const notification = this.getReportingNotification(notificationId); + if (!notification) { + return null; + } + + const instruction = notification.instruction_id + ? this.getInstruction(notification.instruction_id) + : null; + + return { + group_header: buildSpecGroupHeader({ + prefix: 'NTF', + seed: notification.notification_id, + createdAt: notification.created_at ?? notification.booking_date_time ?? nowIso(), + recipient: notification.servicing_agent ?? notification.party ?? null, + }), + notification_id: notification.notification_id, + notification_type: deriveReportingEntryLifecycle(notification).notificationType, + account: buildSpecWalletAccount(notification), + entry: buildSpecBlockchainEntry( + notification, + instruction, + this.chainAdapter, + ), + }; + } + + getSpecIntradayReport(filters = {}) { + const pageSize = Number.parseInt(filters.page_size ?? '50', 10) || 50; + const offset = decodeCursor(filters.cursor ?? filters.after); + const allRecords = sortReportingNotificationRecords( + this.filterReportingNotificationRecords(filters), + filters.sort ?? 'booking_date_desc', + ); + const page = allRecords.slice(offset, offset + pageSize); + const nextOffset = offset + page.length; + const generatedAt = nowIso(); + const representativeRecord = allRecords[0] ?? null; + const instructionLookup = this.buildReportingInstructionLookup(page); + const period = { + from_date_time: + filters.booked_from ?? + new Date( + Date.UTC( + new Date(generatedAt).getUTCFullYear(), + new Date(generatedAt).getUTCMonth(), + new Date(generatedAt).getUTCDate(), + ), + ).toISOString(), + to_date_time: filters.booked_to ?? generatedAt, + }; + const debitCount = allRecords.filter( + (record) => record.entry_type === 'DEBIT', + ).length; + const creditCount = allRecords.filter( + (record) => record.entry_type === 'CREDIT', + ).length; + + return { + group_header: buildSpecGroupHeader({ + prefix: 'INTRA', + seed: `${filters.wallet_address ?? ''}:${filters.chain_dli ?? ''}:${period.from_date_time}:${period.to_date_time}`, + createdAt: generatedAt, + recipient: + representativeRecord?.servicing_agent ?? + representativeRecord?.party ?? + null, + }), + report: { + identification: buildStableReportingIdentifier( + 'RPT', + `${filters.wallet_address ?? ''}:${filters.chain_dli ?? ''}:${period.from_date_time}:${period.to_date_time}`, + ), + creation_date_time: generatedAt, + from_to_date: period, + account: buildSpecWalletAccount(representativeRecord, filters), + balances: buildSpecBalanceAggregation( + allRecords, + 'INTRADAY', + generatedAt, + ), + total_entries: allRecords.length, + total_credit_entries: creditCount, + total_debit_entries: debitCount, + next_cursor: + nextOffset < allRecords.length ? encodeCursor(nextOffset) : null, + entries: page.map((record) => + buildSpecBlockchainEntry( + record, + instructionLookup.get(record.instruction_id) ?? null, + this.chainAdapter, + ), + ), + }, + }; + } + + getSpecStatementReport(filters = {}) { + const pageSize = Number.parseInt(filters.page_size ?? '50', 10) || 50; + const offset = decodeCursor(filters.cursor ?? filters.after); + const statementFilters = { + ...filters, + entry_status: 'BOOK', + }; + const allRecords = sortReportingNotificationRecords( + this.filterReportingNotificationRecords(statementFilters), + filters.sort ?? 'booking_date_asc', + ); + if (!allRecords.length) { + return null; + } + + const page = allRecords.slice(offset, offset + pageSize); + const nextOffset = offset + page.length; + const generatedAt = nowIso(); + const representativeRecord = allRecords[0]; + const instructionLookup = this.buildReportingInstructionLookup(page); + const fromDateTime = + filters.booked_from ?? allRecords[0].booking_date_time ?? generatedAt; + const toDateTime = + filters.booked_to ?? + allRecords.at(-1)?.booking_date_time ?? + generatedAt; + const debitCount = allRecords.filter( + (record) => record.entry_type === 'DEBIT', + ).length; + const creditCount = allRecords.filter( + (record) => record.entry_type === 'CREDIT', + ).length; + + return { + group_header: buildSpecGroupHeader({ + prefix: 'STMT', + seed: `${filters.wallet_address ?? ''}:${filters.chain_dli ?? ''}:${fromDateTime}:${toDateTime}`, + createdAt: generatedAt, + recipient: + representativeRecord?.servicing_agent ?? + representativeRecord?.party ?? + null, + }), + statement: { + identification: buildStableReportingIdentifier( + 'STM', + `${filters.wallet_address ?? ''}:${filters.chain_dli ?? ''}:${fromDateTime}:${toDateTime}`, + ), + creation_date_time: generatedAt, + from_to_date: { + from_date_time: fromDateTime, + to_date_time: toDateTime, + }, + account: buildSpecWalletAccount(representativeRecord, filters), + balances: buildSpecBalanceAggregation( + allRecords, + 'STATEMENT', + generatedAt, + ), + total_entries: allRecords.length, + total_credit_entries: creditCount, + total_debit_entries: debitCount, + next_cursor: + nextOffset < allRecords.length ? encodeCursor(nextOffset) : null, + entries: page.map((record) => + buildSpecBlockchainEntry( + record, + instructionLookup.get(record.instruction_id) ?? null, + this.chainAdapter, + ), + ), + }, + }; + } + + searchReportEntries(filters = {}) { + const pageSize = Number.parseInt(filters.page_size ?? '50', 10) || 50; + const offset = decodeCursor(filters.cursor ?? filters.after); + const records = sortReportingNotificationRecords( + this.filterReportingNotificationRecords(filters), + filters.sort, + ); + const page = records.slice(offset, offset + pageSize); + const nextOffset = offset + page.length; + + return { + total_matched: records.length, + page_size: page.length, + ...(nextOffset < records.length ? { next_cursor: encodeCursor(nextOffset) } : { next_cursor: null }), + query_period: { + from_date_time: filters.booked_from ?? null, + to_date_time: filters.booked_to ?? null, + }, + generated_at: nowIso(), + entries: page.map((record) => buildReportEntrySummary(record)), + }; + } + + getReportStats(filters = {}) { + const records = this.filterReportingNotificationRecords(filters); + const response = { + wallet_address: filters.wallet_address ?? null, + chain_dli: filters.chain_dli ?? null, + period: { + from_date_time: filters.booked_from ?? null, + to_date_time: filters.booked_to ?? null, + }, + generated_at: nowIso(), + totals: buildReportTokenStats(records), + }; + + if (!filters.group_by) { + return response; + } + + const groups = new Map(); + for (const record of records) { + const entry = buildReportEntrySummary(record); + let dimensionValue = null; + switch (filters.group_by) { + case 'entry_status': + dimensionValue = entry.entry_status; + break; + case 'finality_status': + dimensionValue = entry.finality_status; + break; + case 'credit_debit': + dimensionValue = entry.credit_debit_indicator; + break; + case 'token': + dimensionValue = entry.token_symbol ?? entry.token_dti ?? 'UNKNOWN'; + break; + case 'day': + dimensionValue = entry.booking_date ? entry.booking_date.slice(0, 10) : 'UNKNOWN'; + break; + case 'counterparty_wallet': + dimensionValue = record.counterparty?.wallet_address ?? 'UNKNOWN'; + break; + default: + dimensionValue = 'UNKNOWN'; + } + const existing = groups.get(dimensionValue) ?? []; + existing.push(record); + groups.set(dimensionValue, existing); + } + + return { + ...response, + breakdown: Array.from(groups.entries()).map(([dimensionValue, groupedRecords]) => ({ + dimension_value: dimensionValue, + totals: buildReportTokenStats(groupedRecords), + })), + }; + } + + getReportingStatement(statementId) { + const row = this.getReportingStatementStmt.get(statementId); + return row ? parseJson(row.statement_json, null) : null; + } + + getReportingStatementByKey(statementKey) { + const row = this.getReportingStatementByKeyStmt.get(statementKey); + return row ? parseJson(row.statement_json, null) : null; + } + + listReportingStatementRecords() { + return this.listReportingStatementsStmt + .all() + .map((row) => parseJson(row.statement_json, null)) + .filter(Boolean); + } + + filterReportingStatementRecords(filters = {}) { + const accountRoles = parseListFilter(filters.account_role); + return this.listReportingStatementRecords().filter((record) => { + if (filters.instruction_id && record.instruction_id !== filters.instruction_id) { + return false; + } + if (filters.uetr && record.uetr !== filters.uetr) { + return false; + } + if (accountRoles.length && !accountRoles.includes(record.account_role)) { + return false; + } + if (filters.wallet_address && record.party?.wallet_address !== filters.wallet_address) { + return false; + } + if (filters.chain_dli && record.chain_dli !== filters.chain_dli) { + return false; + } + if ( + filters.token_dti && + record.token?.token_dti !== filters.token_dti + ) { + return false; + } + if ( + filters.statement_date_from && + record.statement_date < filters.statement_date_from + ) { + return false; + } + if ( + filters.statement_date_to && + record.statement_date > filters.statement_date_to + ) { + return false; + } + if ( + filters.booked_from && + record.period?.to && + Date.parse(record.period.to) < Date.parse(filters.booked_from) + ) { + return false; + } + if ( + filters.booked_to && + record.period?.from && + Date.parse(record.period.from) > Date.parse(filters.booked_to) + ) { + return false; + } + return true; + }); + } + + listReportingStatements(filters = {}) { + const pageSize = Number.parseInt(filters.page_size ?? '50', 10) || 50; + const offset = decodeCursor(filters.cursor); + const records = this.filterReportingStatementRecords(filters); + const page = records.slice(offset, offset + pageSize); + const nextOffset = offset + page.length; + + return { + total_matched: records.length, + page_size: page.length, + ...(nextOffset < records.length ? { next_cursor: encodeCursor(nextOffset) } : { next_cursor: null }), + generated_at: nowIso(), + statements: page.map((record) => buildReportingStatementSummary(record)), + }; + } + + getReportingStatementForAccount(filters = {}) { + const records = [...this.filterReportingStatementRecords(filters)].sort( + (left, right) => + Date.parse(right.period?.to ?? right.statement_date ?? right.updated_at ?? nowIso()) - + Date.parse(left.period?.to ?? left.statement_date ?? left.updated_at ?? nowIso()), + ); + const selected = records[0] ?? null; + return selected ? this.getReportingStatement(selected.statement_id) : null; + } + + getIntradayReportingView(filters = {}) { + const records = this.filterReportingNotificationRecords(filters); + const debitRecords = records.filter((record) => record.entry_type === 'DEBIT'); + const creditRecords = records.filter((record) => record.entry_type === 'CREDIT'); + const instructionIds = Array.from( + new Set(records.map((record) => record.instruction_id).filter(Boolean)), + ); + const uetrs = Array.from( + new Set(records.map((record) => record.uetr).filter(Boolean)), + ); + const travelRuleRecordIds = Array.from( + new Set( + records.map((record) => record.travel_rule_record_id).filter(Boolean), + ), + ); + const transactionHashes = Array.from( + new Set(records.map((record) => record.transaction_hash).filter(Boolean)), + ); + const representativeRecord = records[0] ?? null; + + return { + period: { + from: filters.booked_from ?? null, + to: filters.booked_to ?? null, + }, + filters_applied: { + instruction_id: filters.instruction_id ?? null, + uetr: filters.uetr ?? null, + account_role: filters.account_role ?? null, + wallet_address: filters.wallet_address ?? null, + chain_dli: filters.chain_dli ?? null, + token_dti: filters.token_dti ?? null, + }, + generated_at: nowIso(), + traceability: + representativeRecord && instructionIds.length === 1 + ? buildReportingTraceability({ + instructionId: representativeRecord.instruction_id, + uetr: representativeRecord.uetr, + endToEndIdentification: + representativeRecord.end_to_end_identification ?? null, + travelRuleRecordId: + representativeRecord.travel_rule_record_id ?? null, + transactionHash: + representativeRecord.transaction_hash ?? null, + }) + : { + instruction_ids: instructionIds, + uetrs, + travel_rule_record_ids: travelRuleRecordIds, + transaction_hashes: transactionHashes, + }, + movement_summary: { + notification_count: records.length, + debit_count: debitRecords.length, + credit_count: creditRecords.length, + totals: buildReportingMovementTotals(records), + }, + account_views: buildIntradayAccountViews(records), + notifications: records.map((record) => buildReportingNotificationSummary(record)), + }; + } + + saveInstruction(record, { + previousRecord = null, + emitEvents = false, + emitNotifications = false, + } = {}) { + this.updateInstructionStmt.run( + record.status, + record.updated_at, + serialize(record), + record.instruction_id, + ); + if (emitEvents) { + this.appendInstructionOutboxEvents(record, previousRecord); + } + if (emitNotifications) { + this.appendReportingNotifications(record); + } + return record; + } + + getInstruction(instructionId) { + const row = this.getInstructionStmt.get(instructionId); + if (!row) { + return null; + } + + const current = parseJson(row.instruction_json, null); + if (!current) { + return null; + } + + return this.advanceInstructionLifecycle(current, { persist: true }); + } + + async getInstructionAsync(instructionId) { + const row = this.getInstructionStmt.get(instructionId); + if (!row) { + return null; + } + + const current = parseJson(row.instruction_json, null); + if (!current) { + return null; + } + + return this.advanceInstructionLifecycleAsync(current, { persist: true }); + } + + cancelInstruction(instructionId) { + const current = this.getInstruction(instructionId); + if (!current) { + return null; + } + + if (!['PENDING', 'QUOTED'].includes(current.status)) { + return { error: 'too_late', current }; + } + + const cancelledAt = nowIso(); + const updated = { + ...current, + status: 'CANCELLED', + updated_at: cancelledAt, + failure_reason: null, + status_history: appendInstructionStatusEvent( + current, + 'CANCELLED', + cancelledAt, + ), + on_chain_settlement: { + ...this.chainAdapter.normalizeOnChainSettlement( + current.on_chain_settlement, + current.interbank_settlement_amount?.amount, + current, + ), + transaction_hash: null, + confirmation_depth: null, + finality_status: null, + block_number: null, + block_timestamp: null, + }, + }; + + this.saveInstruction(updated, { + previousRecord: current, + emitEvents: true, + emitNotifications: true, + }); + + return { + record: updated, + cancellation: buildCancellationResponse(updated, cancelledAt), + }; + } + + async cancelInstructionAsync(instructionId) { + const current = await this.getInstructionAsync(instructionId); + if (!current) { + return null; + } + + if (!['PENDING', 'QUOTED'].includes(current.status)) { + return { error: 'too_late', current }; + } + + const cancelledAt = nowIso(); + const updated = { + ...current, + status: 'CANCELLED', + updated_at: cancelledAt, + failure_reason: null, + status_history: appendInstructionStatusEvent( + current, + 'CANCELLED', + cancelledAt, + ), + on_chain_settlement: { + ...(await this.chainAdapter.normalizeOnChainSettlement( + current.on_chain_settlement, + current.interbank_settlement_amount?.amount, + current, + )), + transaction_hash: null, + confirmation_depth: null, + finality_status: null, + block_number: null, + block_timestamp: null, + }, + }; + + this.saveInstruction(updated, { + previousRecord: current, + emitEvents: true, + emitNotifications: true, + }); + + return { + record: updated, + cancellation: buildCancellationResponse(updated, cancelledAt), + }; + } + + searchInstructions(filters = {}) { + const pageSize = Number.parseInt(filters.page_size ?? '50', 10) || 50; + const statuses = parseListFilter(filters.status); + const offset = decodeCursor(filters.cursor); + const instructions = this.listInstructions().filter((record) => { + if (statuses.length && !statuses.includes(record.status)) { + return false; + } + + if ( + filters.chain_dli && + record.blockchain_instruction?.chain_dli !== filters.chain_dli + ) { + return false; + } + + if ( + filters.token_dti && + record.blockchain_instruction?.token?.token_dti !== filters.token_dti + ) { + return false; + } + + if (filters.from && Date.parse(record.created_at) < Date.parse(filters.from)) { + return false; + } + + if (filters.to && Date.parse(record.created_at) > Date.parse(filters.to)) { + return false; + } + + return true; + }); + const page = instructions.slice(offset, offset + pageSize); + const nextOffset = offset + page.length; + + return { + total_matched: instructions.length, + page_size: page.length, + ...(nextOffset < instructions.length ? { next_cursor: encodeCursor(nextOffset) } : { next_cursor: null }), + generated_at: nowIso(), + instructions: page.map((record) => buildInstructionSearchSummary(record)), + }; + } + + async searchInstructionsAsync(filters = {}) { + const pageSize = Number.parseInt(filters.page_size ?? '50', 10) || 50; + const statuses = parseListFilter(filters.status); + const offset = decodeCursor(filters.cursor); + const allInstructions = await this.listInstructionsAsync(); + const instructions = allInstructions.filter((record) => { + if (statuses.length && !statuses.includes(record.status)) { + return false; + } + + if ( + filters.chain_dli && + record.blockchain_instruction?.chain_dli !== filters.chain_dli + ) { + return false; + } + + if ( + filters.token_dti && + record.blockchain_instruction?.token?.token_dti !== filters.token_dti + ) { + return false; + } + + if (filters.from && Date.parse(record.created_at) < Date.parse(filters.from)) { + return false; + } + + if (filters.to && Date.parse(record.created_at) > Date.parse(filters.to)) { + return false; + } + + return true; + }); + const page = instructions.slice(offset, offset + pageSize); + const nextOffset = offset + page.length; + + return { + total_matched: instructions.length, + page_size: page.length, + ...(nextOffset < instructions.length ? { next_cursor: encodeCursor(nextOffset) } : { next_cursor: null }), + generated_at: nowIso(), + instructions: page.map((record) => buildInstructionSearchSummary(record)), + }; + } + + advanceInstructionLifecycle(record, { persist = false } = {}) { + const normalized = { + ...record, + on_chain_settlement: + record.on_chain_settlement ?? + this.chainAdapter.normalizeOnChainSettlement( + record.on_chain_settlement, + record.interbank_settlement_amount?.amount, + record, + ), + status_history: normalizeInstructionStatusHistory(record.status_history, record), + }; + + if (!['PENDING', 'BROADCAST', 'CONFIRMING'].includes(normalized.status)) { + if (persist && serialize(normalized) !== serialize(record)) { + this.saveInstruction(normalized, { + previousRecord: record, + emitEvents: true, + emitNotifications: true, + }); + } + return normalized; + } + + const lifecycleState = this.chainAdapter.deriveLifecycleState(normalized); + const updated = buildInstructionLifecycleUpdate(normalized, lifecycleState); + + if (persist && serialize(updated) !== serialize(record)) { + this.saveInstruction(updated, { + previousRecord: record, + emitEvents: true, + emitNotifications: true, + }); + } + + return updated; + } + + async advanceInstructionLifecycleAsync(record, { persist = false } = {}) { + const normalized = { + ...record, + on_chain_settlement: + record.on_chain_settlement ?? + (await this.chainAdapter.normalizeOnChainSettlement( + record.on_chain_settlement, + record.interbank_settlement_amount?.amount, + record, + )), + status_history: normalizeInstructionStatusHistory(record.status_history, record), + }; + + if (!['PENDING', 'BROADCAST', 'CONFIRMING'].includes(normalized.status)) { + if (persist && serialize(normalized) !== serialize(record)) { + this.saveInstruction(normalized, { + previousRecord: record, + emitEvents: true, + emitNotifications: true, + }); + } + return normalized; + } + + const lifecycleState = await this.chainAdapter.deriveLifecycleState(normalized); + const updated = buildInstructionLifecycleUpdate(normalized, lifecycleState); + + if (persist && serialize(updated) !== serialize(record)) { + this.saveInstruction(updated, { + previousRecord: record, + emitEvents: true, + emitNotifications: true, + }); + } + + return updated; + } +} diff --git a/reference-server/src/routes/event-routes.js b/reference-server/src/routes/event-routes.js new file mode 100644 index 0000000..d97f609 --- /dev/null +++ b/reference-server/src/routes/event-routes.js @@ -0,0 +1,21 @@ +function sendNotFound(reply, resourceName) { + return reply.code(404).send({ + error: 'not_found', + message: `${resourceName} not found.`, + }); +} + +export function registerEventRoutes(app) { + app.get('/event-outbox', async (request) => { + return app.store.listOutboxEvents(request.query); + }); + + app.get('/event-outbox/:eventId', async (request, reply) => { + const event = app.store.getOutboxEvent(request.params.eventId); + if (!event) { + return sendNotFound(reply, 'Event outbox record'); + } + + return event; + }); +} diff --git a/reference-server/src/routes/exception-routes.js b/reference-server/src/routes/exception-routes.js new file mode 100644 index 0000000..d155774 --- /dev/null +++ b/reference-server/src/routes/exception-routes.js @@ -0,0 +1,445 @@ +import { + formatValidationErrors, + validateInvestigationCaseSearchQuery, + validateInvestigationCaseSubmission, + validateInvestigationCaseUpdate, + validateReturnCaseSearchQuery, + validateReturnCaseSubmission, + validateReturnCaseUpdate, +} from '../validators.js'; + +function sendValidationError(reply, errors) { + return reply.code(400).send({ + error: 'invalid_request', + code: 'INVALID_REQUEST', + message: 'Request validation failed.', + details: formatValidationErrors(errors), + }); +} + +function sendNotFound(reply, resourceName) { + return reply.code(404).send({ + error: 'not_found', + message: `${resourceName} not found.`, + }); +} + +async function resolveInstructionReference(store, { + instructionId = null, + uetr = null, + instructionField = 'related_instruction_id', + uetrField = 'related_uetr', +} = {}) { + const byInstructionId = instructionId ? await store.getInstructionAsync(instructionId) : null; + const byUetr = uetr ? await store.findInstructionByUetrAsync(uetr) : null; + + if (instructionId && !byInstructionId) { + return { + error: { + error: 'invalid_reference', + message: `${instructionField} does not reference a known instruction.`, + }, + }; + } + + if (uetr && !byUetr) { + return { + error: { + error: 'invalid_reference', + message: `${uetrField} does not reference a known instruction.`, + }, + }; + } + + if ( + byInstructionId && + byUetr && + byInstructionId.instruction_id !== byUetr.instruction_id + ) { + return { + error: { + error: 'invalid_reference', + message: `${instructionField} and ${uetrField} must reference the same instruction.`, + }, + }; + } + + return { + instruction: byInstructionId ?? byUetr ?? null, + }; +} + +function sendReferenceError(reply, payload) { + return reply.code(400).send(payload); +} + +function sendDomainError(reply, error) { + if (error?.code === 'CONFLICT') { + return reply.code(409).send({ + error: 'invalid_state', + message: error.message, + }); + } + + if (error?.code === 'INVALID_REQUEST') { + return reply.code(400).send({ + error: 'invalid_request', + message: error.message, + }); + } + + throw error; +} + +function resolveExceptionReportingReferences(store, { + instructionId, + affectedNotificationIds, + affectedStatementIds, +} = {}) { + const normalizedNotificationIds = Array.isArray(affectedNotificationIds) + ? Array.from(new Set(affectedNotificationIds.filter(Boolean))) + : undefined; + const normalizedStatementIds = Array.isArray(affectedStatementIds) + ? Array.from(new Set(affectedStatementIds.filter(Boolean))) + : undefined; + + if (normalizedNotificationIds) { + for (const notificationId of normalizedNotificationIds) { + const notification = store.getReportingNotification(notificationId); + if (!notification) { + return { + error: { + error: 'invalid_reference', + message: `affected_notification_ids contains unknown notification_id ${notificationId}.`, + }, + }; + } + if (notification.instruction_id !== instructionId) { + return { + error: { + error: 'invalid_reference', + message: + 'affected_notification_ids must reference reporting records for the same underlying instruction.', + }, + }; + } + } + } + + if (normalizedStatementIds) { + for (const statementId of normalizedStatementIds) { + const statement = store.getReportingStatement(statementId); + if (!statement) { + return { + error: { + error: 'invalid_reference', + message: `affected_statement_ids contains unknown statement_id ${statementId}.`, + }, + }; + } + if (statement.instruction_id !== instructionId) { + return { + error: { + error: 'invalid_reference', + message: + 'affected_statement_ids must reference reporting records for the same underlying instruction.', + }, + }; + } + } + } + + return { + affected_notification_ids: normalizedNotificationIds, + affected_statement_ids: normalizedStatementIds, + }; +} + +export function registerExceptionRoutes(app) { + app.post('/exceptions/investigations', async (request, reply) => { + const errors = validateInvestigationCaseSubmission(request.body); + if (errors.length) { + return sendValidationError(reply, errors); + } + + const reference = await resolveInstructionReference(app.store, { + instructionId: request.body.related_instruction_id, + uetr: request.body.related_uetr, + instructionField: 'related_instruction_id', + uetrField: 'related_uetr', + }); + if (reference.error) { + return sendReferenceError(reply, reference.error); + } + + let linkedReturnCase = null; + if (request.body.linked_return_case_id) { + linkedReturnCase = app.store.getReturnCase(request.body.linked_return_case_id); + if (!linkedReturnCase) { + return sendReferenceError(reply, { + error: 'invalid_reference', + message: 'linked_return_case_id does not reference a known return case.', + }); + } + if ( + linkedReturnCase.related_instruction_id !== + reference.instruction.instruction_id + ) { + return sendReferenceError(reply, { + error: 'invalid_reference', + message: 'linked_return_case_id must reference the same underlying instruction.', + }); + } + } + + const reportingReferences = resolveExceptionReportingReferences(app.store, { + instructionId: reference.instruction.instruction_id, + affectedNotificationIds: request.body.affected_notification_ids, + affectedStatementIds: request.body.affected_statement_ids, + }); + if (reportingReferences.error) { + return sendReferenceError(reply, reportingReferences.error); + } + + const investigation = app.store.createInvestigationCase({ + ...request.body, + ...reportingReferences, + }, { + instruction: reference.instruction, + linkedReturnCase, + }); + return reply.code(201).send(investigation); + }); + + app.patch('/exceptions/investigations/:caseId', async (request, reply) => { + const errors = validateInvestigationCaseUpdate(request.body); + if (errors.length) { + return sendValidationError(reply, errors); + } + + const existing = app.store.getInvestigationCase(request.params.caseId); + if (!existing) { + return sendNotFound(reply, 'Investigation case'); + } + + let linkedReturnCase = null; + if (request.body.linked_return_case_id) { + linkedReturnCase = app.store.getReturnCase(request.body.linked_return_case_id); + if (!linkedReturnCase) { + return sendReferenceError(reply, { + error: 'invalid_reference', + message: 'linked_return_case_id does not reference a known return case.', + }); + } + if ( + linkedReturnCase.related_instruction_id !== existing.related_instruction_id + ) { + return sendReferenceError(reply, { + error: 'invalid_reference', + message: 'linked_return_case_id must reference the same underlying instruction.', + }); + } + } + + const reportingReferences = resolveExceptionReportingReferences(app.store, { + instructionId: existing.related_instruction_id, + affectedNotificationIds: request.body.affected_notification_ids, + affectedStatementIds: request.body.affected_statement_ids, + }); + if (reportingReferences.error) { + return sendReferenceError(reply, reportingReferences.error); + } + + try { + return app.store.updateInvestigationCase( + request.params.caseId, + { + ...request.body, + ...reportingReferences, + }, + { + linkedReturnCase, + }, + ); + } catch (error) { + return sendDomainError(reply, error); + } + }); + + app.get('/exceptions/investigations', async (request, reply) => { + const errors = validateInvestigationCaseSearchQuery(request.query); + if (errors.length) { + return sendValidationError(reply, errors); + } + + return app.store.listInvestigationCases(request.query); + }); + + app.get('/exceptions/investigations/:caseId', async (request, reply) => { + const investigation = app.store.getInvestigationCase(request.params.caseId); + if (!investigation) { + return sendNotFound(reply, 'Investigation case'); + } + + return investigation; + }); + + app.post('/exceptions/returns', async (request, reply) => { + const errors = validateReturnCaseSubmission(request.body); + if (errors.length) { + return sendValidationError(reply, errors); + } + + const reference = await resolveInstructionReference(app.store, { + instructionId: request.body.original_instruction_id, + uetr: request.body.original_uetr, + instructionField: 'original_instruction_id', + uetrField: 'original_uetr', + }); + if (reference.error) { + return sendReferenceError(reply, reference.error); + } + + if (reference.instruction.status !== 'FINAL') { + return sendReferenceError(reply, { + error: 'invalid_state', + message: 'Return cases can only be opened for instructions in FINAL status.', + }); + } + + if ( + request.body.compensating_instruction_id && + !(await app.store.getInstructionAsync(request.body.compensating_instruction_id)) + ) { + return sendReferenceError(reply, { + error: 'invalid_reference', + message: 'compensating_instruction_id does not reference a known instruction.', + }); + } + + let linkedInvestigationCase = null; + if (request.body.linked_investigation_case_id) { + linkedInvestigationCase = app.store.getInvestigationCase( + request.body.linked_investigation_case_id, + ); + if (!linkedInvestigationCase) { + return sendReferenceError(reply, { + error: 'invalid_reference', + message: 'linked_investigation_case_id does not reference a known investigation case.', + }); + } + if ( + linkedInvestigationCase.related_instruction_id !== + reference.instruction.instruction_id + ) { + return sendReferenceError(reply, { + error: 'invalid_reference', + message: 'linked_investigation_case_id must reference the same underlying instruction.', + }); + } + } + + const reportingReferences = resolveExceptionReportingReferences(app.store, { + instructionId: reference.instruction.instruction_id, + affectedNotificationIds: request.body.affected_notification_ids, + affectedStatementIds: request.body.affected_statement_ids, + }); + if (reportingReferences.error) { + return sendReferenceError(reply, reportingReferences.error); + } + + const returnCase = app.store.createReturnCase({ + ...request.body, + ...reportingReferences, + }, { + instruction: reference.instruction, + linkedInvestigationCase, + }); + return reply.code(201).send(returnCase); + }); + + app.patch('/exceptions/returns/:returnCaseId', async (request, reply) => { + const errors = validateReturnCaseUpdate(request.body); + if (errors.length) { + return sendValidationError(reply, errors); + } + + const existing = app.store.getReturnCase(request.params.returnCaseId); + if (!existing) { + return sendNotFound(reply, 'Return case'); + } + + let linkedInvestigationCase = null; + if (request.body.linked_investigation_case_id) { + linkedInvestigationCase = app.store.getInvestigationCase( + request.body.linked_investigation_case_id, + ); + if (!linkedInvestigationCase) { + return sendReferenceError(reply, { + error: 'invalid_reference', + message: 'linked_investigation_case_id does not reference a known investigation case.', + }); + } + if ( + linkedInvestigationCase.related_instruction_id !== existing.related_instruction_id + ) { + return sendReferenceError(reply, { + error: 'invalid_reference', + message: 'linked_investigation_case_id must reference the same underlying instruction.', + }); + } + } + + if ( + request.body.compensating_instruction_id && + !(await app.store.getInstructionAsync(request.body.compensating_instruction_id)) + ) { + return sendReferenceError(reply, { + error: 'invalid_reference', + message: 'compensating_instruction_id does not reference a known instruction.', + }); + } + + const reportingReferences = resolveExceptionReportingReferences(app.store, { + instructionId: existing.related_instruction_id, + affectedNotificationIds: request.body.affected_notification_ids, + affectedStatementIds: request.body.affected_statement_ids, + }); + if (reportingReferences.error) { + return sendReferenceError(reply, reportingReferences.error); + } + + try { + return app.store.updateReturnCase( + request.params.returnCaseId, + { + ...request.body, + ...reportingReferences, + }, + { + linkedInvestigationCase, + }, + ); + } catch (error) { + return sendDomainError(reply, error); + } + }); + + app.get('/exceptions/returns', async (request, reply) => { + const errors = validateReturnCaseSearchQuery(request.query); + if (errors.length) { + return sendValidationError(reply, errors); + } + + return app.store.listReturnCases(request.query); + }); + + app.get('/exceptions/returns/:returnCaseId', async (request, reply) => { + const returnCase = app.store.getReturnCase(request.params.returnCaseId); + if (!returnCase) { + return sendNotFound(reply, 'Return case'); + } + + return returnCase; + }); +} diff --git a/reference-server/src/routes/health-routes.js b/reference-server/src/routes/health-routes.js new file mode 100644 index 0000000..331eb7c --- /dev/null +++ b/reference-server/src/routes/health-routes.js @@ -0,0 +1,14 @@ +export function registerHealthRoutes(app) { + app.get('/health', async () => ({ + status: 'ok', + service: 'pacs.crypto reference server', + supported_flows: [ + 'travel_rule_submission', + 'travel_rule_callback', + 'travel_rule_search', + 'instruction_quote', + 'instruction_submission', + 'instruction_status', + ], + })); +} diff --git a/reference-server/src/routes/instruction-routes.js b/reference-server/src/routes/instruction-routes.js new file mode 100644 index 0000000..0a49f3a --- /dev/null +++ b/reference-server/src/routes/instruction-routes.js @@ -0,0 +1,269 @@ +import { + formatValidationErrors, + validateInstructionSearchQuery, + validateInstructionSubmission, + validateQuoteRequest, + validateTomReturnRequest, + validateTomReversalRequest, +} from '../validators.js'; + +const UUID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +function sendValidationError(reply, errors) { + return reply.code(400).send({ + error: 'invalid_request', + code: 'INVALID_REQUEST', + message: 'Request validation failed.', + details: formatValidationErrors(errors), + }); +} + +function sendInvalidPathUuid(reply, field) { + return reply.code(400).send({ + error: 'invalid_request', + code: 'INVALID_REQUEST', + message: `${field} must be a UUID.`, + details: [{ field, issue: `${field} must be a UUID.` }], + }); +} + +export function registerInstructionRoutes(app) { + app.post('/instruction/quote', async (request, reply) => { + const errors = validateQuoteRequest(request.body); + if (errors.length) { + return sendValidationError(reply, errors); + } + + const quote = await app.store.createQuoteAsync(request.body); + return reply.code(200).send(quote); + }); + + app.post('/instruction', async (request, reply) => { + const errors = validateInstructionSubmission(request.body); + if (errors.length) { + return sendValidationError(reply, errors); + } + + const quoteId = request.body.payment_identification?.quote_id; + if (quoteId) { + const quote = app.store.getQuote(quoteId); + if (!quote) { + return reply.code(400).send({ + error: 'invalid_quote', + message: 'Referenced quote_id was not found.', + }); + } + + if (Date.parse(quote.valid_until) < Date.now()) { + return reply.code(400).send({ + error: 'expired_quote', + message: 'Referenced quote_id is no longer valid.', + }); + } + } + + const endToEndIdentification = + request.body.payment_identification?.end_to_end_identification; + if (endToEndIdentification) { + const existing = await app.store.findInstructionByEndToEndIdAsync(endToEndIdentification); + if (existing) { + return reply.code(409).send({ + error: 'duplicate_instruction', + instruction_id: existing.instruction_id, + message: + 'Instruction already exists for this end_to_end_identification.', + }); + } + } + + const custodyModel = + request.body.blockchain_instruction?.custody_model ?? + request.body.custody_model ?? + 'FULL_CUSTODY'; + + if (custodyModel === 'DELEGATED_SIGNING') { + return reply.code(501).send({ + error: 'not_implemented', + message: 'Delegated signing is not implemented in v0.', + }); + } + + const travelRuleRecordId = request.body.travel_rule_record_id; + if (travelRuleRecordId && !app.store.getTravelRuleRecord(travelRuleRecordId)) { + return reply.code(400).send({ + error: 'invalid_reference', + message: 'travel_rule_record_id does not reference a known record.', + }); + } + + const instruction = await app.store.createInstructionAsync(request.body); + return reply.code(201).send(app.store.toInstructionResponse(instruction)); + }); + + app.delete('/instruction/:instructionId', async (request, reply) => { + const result = await app.store.cancelInstructionAsync(request.params.instructionId); + if (!result) { + return reply.code(404).send({ + error: 'not_found', + message: 'Instruction not found.', + }); + } + + if (result.error === 'too_late') { + return reply.code(409).send({ + error: 'cancellation_too_late', + current_status: result.current.status, + message: 'Instruction can only be cancelled in PENDING or QUOTED status.', + }); + } + + return result.cancellation; + }); + + app.get('/instruction/:instructionId', async (request, reply) => { + const instruction = await app.store.getInstructionAsync(request.params.instructionId); + if (!instruction) { + return reply.code(404).send({ + error: 'not_found', + message: 'Instruction not found.', + }); + } + + return app.store.toInstructionDetailResponse(instruction); + }); + + app.post('/instruction/:instructionId/signed-transaction', async (_request, reply) => { + return reply.code(501).send({ + error: 'not_implemented', + message: 'Delegated signing is not implemented in v0.', + }); + }); + + app.get('/instruction/search', async (request, reply) => { + const errors = validateInstructionSearchQuery(request.query); + if (errors.length) { + return sendValidationError(reply, errors); + } + + return app.store.searchInstructionsAsync(request.query); + }); + + app.post('/instruction/:instructionId/return', async (request, reply) => { + const { instructionId } = request.params; + if (!UUID_PATTERN.test(instructionId)) { + return sendInvalidPathUuid(reply, 'instructionId'); + } + + const errors = validateTomReturnRequest(request.body); + if (errors.length) { + return sendValidationError(reply, errors); + } + + const instruction = await app.store.getInstructionAsync(instructionId); + if (!instruction) { + return reply.code(404).send({ + error: 'not_found', + code: 'INSTRUCTION_NOT_FOUND', + message: 'Instruction not found.', + }); + } + + if (instruction.status !== 'FINAL') { + return reply.code(409).send({ + error: 'invalid_state', + code: 'INSTRUCTION_NOT_FINAL', + message: 'Return can only be requested for instructions in FINAL status.', + current_status: instruction.status, + }); + } + + const existing = app.store.findActiveTomCaseForInstruction(instructionId); + if (existing) { + return reply.code(409).send({ + error: 'invalid_state', + code: 'ALREADY_RETURNED', + message: + 'An active Tom-origin return or reversal already exists for this instruction.', + existing_exception_type: existing.exception_type, + existing_exception_case_id: existing.return_case_id, + }); + } + + const { record } = await app.store.createTomReturnAsync(instruction, request.body); + return reply.code(202).send(app.store.toTomReturnResponse(record)); + }); + + app.post('/instruction/:instructionId/reverse', async (request, reply) => { + const { instructionId } = request.params; + if (!UUID_PATTERN.test(instructionId)) { + return sendInvalidPathUuid(reply, 'instructionId'); + } + + const errors = validateTomReversalRequest(request.body); + if (errors.length) { + return sendValidationError(reply, errors); + } + + const instruction = await app.store.getInstructionAsync(instructionId); + if (!instruction) { + return reply.code(404).send({ + error: 'not_found', + code: 'INSTRUCTION_NOT_FOUND', + message: 'Instruction not found.', + }); + } + + if (instruction.status !== 'FINAL') { + return reply.code(409).send({ + error: 'invalid_state', + code: 'INSTRUCTION_NOT_FINAL', + message: + 'Reversal can only be requested for instructions in FINAL status.', + current_status: instruction.status, + }); + } + + const existing = app.store.findActiveTomCaseForInstruction(instructionId); + if (existing) { + return reply.code(409).send({ + error: 'invalid_state', + code: 'ALREADY_RETURNED', + message: + 'An active Tom-origin return or reversal already exists for this instruction.', + existing_exception_type: existing.exception_type, + existing_exception_case_id: existing.return_case_id, + }); + } + + const record = app.store.createTomReversalRequest(instruction, request.body); + return reply.code(202).send(app.store.toTomReversalResponse(record)); + }); + + app.get('/instruction/:instructionId/reversal-status', async (request, reply) => { + const { instructionId } = request.params; + if (!UUID_PATTERN.test(instructionId)) { + return sendInvalidPathUuid(reply, 'instructionId'); + } + + const instruction = await app.store.getInstructionAsync(instructionId); + if (!instruction) { + return reply.code(404).send({ + error: 'not_found', + code: 'INSTRUCTION_NOT_FOUND', + message: 'Instruction not found.', + }); + } + + const reversal = app.store.getLatestReversalForInstruction(instructionId); + if (!reversal) { + return reply.code(404).send({ + error: 'not_found', + code: 'REVERSAL_NOT_FOUND', + message: 'No reversal request exists for this instruction.', + }); + } + + return reply.code(200).send(app.store.toTomReversalStatusResponse(reversal)); + }); +} diff --git a/reference-server/src/routes/reporting-routes.js b/reference-server/src/routes/reporting-routes.js new file mode 100644 index 0000000..9b0fde1 --- /dev/null +++ b/reference-server/src/routes/reporting-routes.js @@ -0,0 +1,307 @@ +import { + formatValidationErrors, + validateReportIntradayQuery, + validateReportQuery, + validateReportSearchQuery, + validateReportStatementQuery, + validateReportStatsQuery, +} from '../validators.js'; + +function sendValidationError(reply, errors) { + return reply.code(400).send({ + error: 'invalid_request', + code: 'INVALID_REQUEST', + message: 'Request validation failed.', + details: formatValidationErrors(errors), + }); +} + +function sendNotFound(reply, resourceName) { + return reply.code(404).send({ + error: 'not_found', + message: `${resourceName} not found.`, + }); +} + +function extractWalletAddressFromAccount(account) { + return account?.identification?.proxy?.identification ?? null; +} + +function extractChainDliFromAccount(account) { + const proprietary = account?.type?.proprietary ?? null; + if (typeof proprietary !== 'string') { + return null; + } + + return proprietary.startsWith('DLID/') ? proprietary.slice(5) : proprietary; +} + +function mapCreditDebitIndicatorToEntryType(value) { + if (value === 'DBIT') { + return 'DEBIT'; + } + if (value === 'CRDT') { + return 'CREDIT'; + } + return null; +} + +function normalizeReportFilters(query = {}) { + return { + instruction_id: query.instruction_id ?? null, + uetr: query.uetr ?? null, + travel_rule_record_id: query.travel_rule_record_id ?? null, + wallet_address: query.wallet_address ?? null, + chain_dli: query.chain_dli ?? null, + token_dti: query.token_dti ?? null, + counterparty_wallet: query.counterparty_wallet ?? null, + transaction_hash: query.transaction_hash ?? null, + booked_from: query.from_date_time ?? query.booked_from ?? null, + booked_to: query.to_date_time ?? query.booked_to ?? null, + entry_status: query.entry_status ?? null, + entry_type: + mapCreditDebitIndicatorToEntryType(query.credit_debit_indicator) ?? + query.entry_type ?? + null, + finality_status: query.finality_status ?? null, + amount_min: query.amount_min ?? null, + amount_max: query.amount_max ?? null, + group_by: query.group_by ?? null, + page_size: query.page_size ?? null, + cursor: query.after ?? query.cursor ?? null, + sort: query.sort ?? null, + }; +} + +function normalizeStatementFilters(query = {}) { + return { + wallet_address: query.wallet_address ?? null, + chain_dli: query.chain_dli ?? null, + token_dti: query.token_dti ?? null, + statement_date_from: query.from_date ?? null, + statement_date_to: query.to_date ?? null, + booked_from: query.from_date ? `${query.from_date}T00:00:00Z` : null, + booked_to: query.to_date ? `${query.to_date}T23:59:59.999Z` : null, + page_size: query.page_size ?? null, + cursor: query.after ?? query.cursor ?? null, + sort: query.sort ?? null, + }; +} + +export function registerReportingRoutes(app) { + app.post('/report/query', async (request, reply) => { + const errors = validateReportQuery(request.body); + if (errors.length) { + return sendValidationError(reply, errors); + } + + const walletAddress = extractWalletAddressFromAccount(request.body.account); + const chainDli = extractChainDliFromAccount(request.body.account); + const tokenDti = request.body.token_filter?.[0]?.token_dti ?? null; + const queryFilters = { + wallet_address: walletAddress, + chain_dli: chainDli, + token_dti: tokenDti, + booked_from: request.body.reporting_period?.from_date_time ?? null, + booked_to: request.body.reporting_period?.to_date_time ?? null, + entry_status: Array.isArray(request.body.entry_status_filter) + ? request.body.entry_status_filter.join(',') + : null, + }; + + switch (request.body.query_type) { + case 'BALANCE': + return reply.code(201).send({ + query_identification: request.body.query_identification, + query_type: request.body.query_type, + balances: app.store.getReportBalanceResponse(queryFilters), + }); + + case 'INTRADAY': + return reply.code(201).send({ + query_identification: request.body.query_identification, + query_type: request.body.query_type, + intraday_report: app.store.getSpecIntradayReport(queryFilters), + }); + + case 'STATEMENT': { + const statement = app.store.getSpecStatementReport({ + wallet_address: walletAddress, + chain_dli: chainDli, + token_dti: tokenDti, + statement_date_from: + request.body.reporting_period?.from_date_time?.slice(0, 10) ?? null, + statement_date_to: + request.body.reporting_period?.to_date_time?.slice(0, 10) ?? null, + booked_from: request.body.reporting_period?.from_date_time ?? null, + booked_to: request.body.reporting_period?.to_date_time ?? null, + }); + if (!statement) { + return sendNotFound(reply, 'Wallet statement'); + } + + if (request.body.callback_url) { + app.store.createReportStatementCallback({ + callbackUrl: request.body.callback_url, + queryIdentification: request.body.query_identification, + walletAddress, + chainDli, + statement, + }); + + return reply.code(202).send({ + query_identification: request.body.query_identification, + message: + 'Statement will be delivered asynchronously to the provided callback_url.', + }); + } + + return reply.code(201).send({ + query_identification: request.body.query_identification, + query_type: request.body.query_type, + statement, + }); + } + + case 'NOTIFICATION_SUBSCRIBE': { + const result = app.store.createReportNotificationSubscription({ + callbackUrl: request.body.callback_url, + walletAddress, + chainDli, + queryIdentification: request.body.query_identification, + }); + + if (result.duplicate) { + return reply.code(409).send({ + subscription_id: result.subscription.subscription_id, + message: + 'An active subscription already exists for this account and callback URL.', + }); + } + + return reply.code(201).send({ + query_identification: request.body.query_identification, + query_type: request.body.query_type, + subscription_id: result.subscription.subscription_id, + subscription_status: 'ACTIVE', + }); + } + + case 'NOTIFICATION_UNSUBSCRIBE': { + const cancelled = app.store.cancelReportNotificationSubscription( + request.body.subscription_id, + ); + if (!cancelled) { + return sendNotFound(reply, 'Notification subscription'); + } + + return reply.code(201).send({ + query_identification: request.body.query_identification, + query_type: request.body.query_type, + subscription_id: cancelled.subscription_id, + subscription_status: 'CANCELLED', + }); + } + + default: + return reply.code(400).send({ + error: 'invalid_request', + message: 'Unsupported query_type.', + }); + } + }); + + app.get('/report/intraday', async (request, reply) => { + const errors = validateReportIntradayQuery(request.query); + if (errors.length) { + return sendValidationError(reply, errors); + } + + return app.store.getSpecIntradayReport(normalizeReportFilters(request.query)); + }); + + app.get('/report/statement', async (request, reply) => { + const errors = validateReportStatementQuery(request.query); + if (errors.length) { + return sendValidationError(reply, errors); + } + + const statement = app.store.getSpecStatementReport( + normalizeStatementFilters(request.query), + ); + if (!statement) { + return sendNotFound(reply, 'Wallet statement'); + } + + return statement; + }); + + app.get('/report/notification/:notificationId', async (request, reply) => { + const notification = app.store.getSpecReportingNotification( + request.params.notificationId, + ); + if (!notification) { + return sendNotFound(reply, 'Reporting notification'); + } + + return notification; + }); + + app.post('/report/notification/callback', async (_request, reply) => { + return reply.code(501).send({ + error: 'not_implemented', + message: + 'The bank-side notification callback endpoint is out of scope for the reference VASP server.', + }); + }); + + app.get('/report/search', async (request, reply) => { + const errors = validateReportSearchQuery(request.query); + if (errors.length) { + return sendValidationError(reply, errors); + } + + return app.store.searchReportEntries(normalizeReportFilters(request.query)); + }); + + app.get('/report/stats', async (request, reply) => { + const errors = validateReportStatsQuery(request.query); + if (errors.length) { + return sendValidationError(reply, errors); + } + + return app.store.getReportStats(normalizeReportFilters(request.query)); + }); + + app.get('/reporting/notifications', async (request) => { + return app.store.listReportingNotifications(request.query); + }); + + app.get('/reporting/notifications/:notificationId', async (request, reply) => { + const notification = app.store.getReportingNotification( + request.params.notificationId, + ); + if (!notification) { + return sendNotFound(reply, 'Reporting notification'); + } + + return notification; + }); + + app.get('/reporting/intraday', async (request) => { + return app.store.getIntradayReportingView(request.query); + }); + + app.get('/reporting/statements', async (request) => { + return app.store.listReportingStatements(request.query); + }); + + app.get('/reporting/statements/:statementId', async (request, reply) => { + const statement = app.store.getReportingStatement(request.params.statementId); + if (!statement) { + return sendNotFound(reply, 'Reporting statement'); + } + + return statement; + }); +} diff --git a/reference-server/src/routes/status-routes.js b/reference-server/src/routes/status-routes.js new file mode 100644 index 0000000..0187636 --- /dev/null +++ b/reference-server/src/routes/status-routes.js @@ -0,0 +1,44 @@ +function sendNotFound(reply, resourceName) { + return reply.code(404).send({ + error: 'not_found', + message: `${resourceName} not found.`, + }); +} + +export function registerStatusRoutes(app) { + app.get('/execution-status/:instructionId', async (request, reply) => { + const instruction = await app.store.getInstructionAsync(request.params.instructionId); + if (!instruction) { + return sendNotFound(reply, 'Instruction execution status'); + } + + return app.store.toExecutionStatusResponse(instruction); + }); + + app.get('/execution-status/uetr/:uetr', async (request, reply) => { + const instruction = await app.store.findInstructionByUetrAsync(request.params.uetr); + if (!instruction) { + return sendNotFound(reply, 'Instruction execution status'); + } + + return app.store.toExecutionStatusResponse(instruction); + }); + + app.get('/finality-receipt/:instructionId', async (request, reply) => { + const instruction = await app.store.getInstructionAsync(request.params.instructionId); + if (!instruction) { + return sendNotFound(reply, 'Instruction finality receipt'); + } + + return app.store.toFinalityReceipt(instruction); + }); + + app.get('/finality-receipt/uetr/:uetr', async (request, reply) => { + const instruction = await app.store.findInstructionByUetrAsync(request.params.uetr); + if (!instruction) { + return sendNotFound(reply, 'Instruction finality receipt'); + } + + return app.store.toFinalityReceipt(instruction); + }); +} diff --git a/reference-server/src/routes/travel-rule-routes.js b/reference-server/src/routes/travel-rule-routes.js new file mode 100644 index 0000000..44ca634 --- /dev/null +++ b/reference-server/src/routes/travel-rule-routes.js @@ -0,0 +1,106 @@ +import { + formatValidationErrors, + validateTravelRuleCallback, + validateTravelRuleSearchQuery, + validateTravelRuleStatsQuery, + validateTravelRuleSubmission, +} from '../validators.js'; + +function sendValidationError(reply, errors) { + return reply.code(400).send({ + error: 'invalid_request', + code: 'INVALID_REQUEST', + message: 'Request validation failed.', + details: formatValidationErrors(errors), + }); +} + +export function registerTravelRuleRoutes(app) { + app.post('/travel-rule', async (request, reply) => { + const errors = validateTravelRuleSubmission(request.body); + if (errors.length) { + return sendValidationError(reply, errors); + } + + const record = app.store.createTravelRuleRecord(request.body); + return reply.code(201).send(record); + }); + + app.get('/travel-rule/search', async (request, reply) => { + const errors = validateTravelRuleSearchQuery(request.query); + if (errors.length) { + return sendValidationError(reply, errors); + } + return app.store.searchTravelRuleResponse(request.query); + }); + + app.get('/travel-rule/stats', async (request, reply) => { + const errors = validateTravelRuleStatsQuery(request.query); + if (errors.length) { + return sendValidationError(reply, errors); + } + + return app.store.getTravelRuleStats(request.query); + }); + + app.get('/travel-rule/:recordId', async (request, reply) => { + const record = app.store.getTravelRuleRecord(request.params.recordId); + if (!record) { + return reply.code(404).send({ + error: 'not_found', + message: 'Travel Rule record not found.', + }); + } + + return record; + }); + + app.put('/travel-rule/:recordId', async (request, reply) => { + const errors = validateTravelRuleSubmission(request.body); + if (errors.length) { + return sendValidationError(reply, errors); + } + + const record = app.store.updateTravelRuleRecord( + request.params.recordId, + request.body, + ); + + if (!record) { + return reply.code(404).send({ + error: 'not_found', + message: 'Travel Rule record not found.', + }); + } + + return record; + }); + + app.post('/travel-rule/:recordId/callback', async (request, reply) => { + const errors = validateTravelRuleCallback(request.body); + if (errors.length) { + return sendValidationError(reply, errors); + } + + try { + const result = app.store.appendTravelRuleCallback( + request.params.recordId, + request.body, + ); + + if (!result) { + return reply.code(404).send({ + error: 'not_found', + message: 'Travel Rule record not found.', + }); + } + + return result.receipt; + } catch (error) { + return reply.code(error.code === 'CONFLICT' ? 409 : 400).send({ + error: 'invalid_request', + message: error.message, + }); + } + }); +} diff --git a/reference-server/src/routes/webhook-routes.js b/reference-server/src/routes/webhook-routes.js new file mode 100644 index 0000000..0d08f2d --- /dev/null +++ b/reference-server/src/routes/webhook-routes.js @@ -0,0 +1,93 @@ +import { + formatValidationErrors, + validateWebhookDispatchRequest, + validateWebhookSubscriptionSubmission, +} from '../validators.js'; + +function sendValidationError(reply, errors) { + return reply.code(400).send({ + error: 'invalid_request', + code: 'INVALID_REQUEST', + message: 'Request validation failed.', + details: formatValidationErrors(errors), + }); +} + +function sendNotFound(reply, resourceName) { + return reply.code(404).send({ + error: 'not_found', + message: `${resourceName} not found.`, + }); +} + +export function registerWebhookRoutes(app) { + app.post('/webhook-endpoints', async (request, reply) => { + const errors = validateWebhookSubscriptionSubmission(request.body); + if (errors.length) { + return sendValidationError(reply, errors); + } + + const subscription = app.store.createWebhookSubscription(request.body); + return reply.code(201).send(subscription); + }); + + app.get('/webhook-endpoints', async () => { + return app.store.listWebhookSubscriptions(); + }); + + app.get('/webhook-endpoints/:subscriptionId/deliveries', async (request, reply) => { + const subscription = app.store.getWebhookSubscription(request.params.subscriptionId); + if (!subscription) { + return sendNotFound(reply, 'Webhook subscription'); + } + + return app.store.listWebhookDeliveries({ + ...request.query, + subscription_id: request.params.subscriptionId, + }); + }); + + app.get('/webhook-endpoints/:subscriptionId', async (request, reply) => { + const subscription = app.store.getWebhookSubscription(request.params.subscriptionId); + if (!subscription) { + return sendNotFound(reply, 'Webhook subscription'); + } + + return subscription; + }); + + app.get('/webhook-deliveries', async (request) => { + return app.store.listWebhookDeliveries(request.query); + }); + + app.get('/webhook-deliveries/stats', async (request) => { + return app.store.getWebhookDeliveryStats(request.query); + }); + + app.get('/webhook-deliveries/dead-letter', async (request) => { + return app.store.listDeadLetterWebhookDeliveries(request.query); + }); + + app.get('/webhook-deliveries/:deliveryId', async (request, reply) => { + const delivery = app.store.getWebhookDelivery(request.params.deliveryId); + if (!delivery) { + return sendNotFound(reply, 'Webhook delivery'); + } + + return delivery; + }); + + app.post('/webhook-deliveries/dispatch', async (request, reply) => { + const errors = validateWebhookDispatchRequest(request.body); + if (errors.length) { + return sendValidationError(reply, errors); + } + + const result = await app.dispatchDueWebhookDeliveries({ + limit: request.body?.limit, + subscriptionId: request.body?.subscription_id ?? null, + }); + + return reply.code(200).send(result); + }); +} diff --git a/reference-server/src/server.js b/reference-server/src/server.js new file mode 100644 index 0000000..0aabb40 --- /dev/null +++ b/reference-server/src/server.js @@ -0,0 +1,33 @@ +import { loadConfig } from './config.js'; +import { buildApp } from './app.js'; +import { createSepoliaUsdcAdapter } from './chain/sepolia-usdc-adapter.js'; + +function buildConfiguredChainAdapter(config) { + if (config.chainAdapter.id === 'sepolia-usdc') { + return createSepoliaUsdcAdapter(config.chainAdapter.sepolia); + } + + return null; +} + +const config = loadConfig(); +const app = await buildApp({ + dbPath: config.dbPath, + chainAdapter: buildConfiguredChainAdapter(config), + webhookDispatch: config.webhookDispatch, + webhookRetryScheduleMs: config.webhookRetryScheduleMs, +}); + +try { + await app.listen({ + host: config.host, + port: config.port, + }); + + console.log( + `pacs.crypto reference server listening on http://${config.host}:${config.port}`, + ); +} catch (error) { + app.log.error(error); + process.exit(1); +} diff --git a/reference-server/src/validators.js b/reference-server/src/validators.js new file mode 100644 index 0000000..7b08188 --- /dev/null +++ b/reference-server/src/validators.js @@ -0,0 +1,2092 @@ +function isObject(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function hasText(value) { + return typeof value === 'string' && value.trim().length > 0; +} + +function isIsoDateTime(value) { + return hasText(value) && !Number.isNaN(Date.parse(value)); +} + +function isUuid(value) { + return ( + hasText(value) && + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test( + value, + ) + ); +} + +function isDecimalString(value) { + return hasText(value) && /^(0|[1-9]\d*)(\.\d+)?$/.test(value); +} + +function hasValidTopLevelOrNestedName(value) { + return hasText(value?.name) || hasText(value?.financial_institution_identification?.name); +} + +function getTopLevelOrNestedLei(value) { + return value?.lei ?? value?.financial_institution_identification?.lei ?? null; +} + +function getTopLevelOrNestedBic(value) { + return value?.bic ?? value?.financial_institution_identification?.bic ?? null; +} + +const CHAIN_DLI_PATTERN = /^[A-Z0-9]{9}$/; +const TOKEN_DTI_PATTERN = /^[A-Z0-9]{9}$/; +const LEI_PATTERN = /^[A-Z0-9]{18}[0-9]{2}$/; +const COUNTRY_CODE_PATTERN = /^[A-Z]{2}$/; +const CURRENCY_CODE_PATTERN = /^[A-Z]{3}$/; +const MAX_SLIPPAGE_PATTERN = /^0*(\d{0,2}\.\d{1,8}|\d{0,3})$/; + +const WEBHOOK_EVENT_TYPES = new Set([ + 'execution_status.updated', + 'finality_receipt.updated', + 'reporting_notification.created', + 'investigation_case.updated', + 'return_case.updated', +]); + +const TRAVEL_RULE_SUBMISSION_TIMINGS = new Set([ + 'PRE_TX', + 'POST_TX', + 'SIMULTANEOUS', +]); + +const TRAVEL_RULE_CALLBACK_STATUSES = new Set([ + 'ACCEPTED', + 'REJECTED', + 'UNDER_REVIEW', +]); + +const TRAVEL_RULE_CALLBACK_FILTER_STATUSES = new Set([ + 'PENDING', + 'ACCEPTED', + 'REJECTED', + 'UNDER_REVIEW', +]); + +const TRAVEL_RULE_RECORD_STATUSES = new Set([ + 'SUBMITTED', + 'ACCEPTED', + 'REJECTED', + 'UNDER_REVIEW', + 'ARCHIVED', +]); + +const TRAVEL_RULE_WALLET_TYPES = new Set([ + 'HOSTED', + 'UNHOSTED', + 'UNKNOWN', +]); + +const CUSTODY_MODELS = new Set([ + 'FULL_CUSTODY', + 'DELEGATED_SIGNING', +]); + +const RAMP_TYPES = new Set([ + 'NONE', + 'ONRAMP', + 'OFFRAMP', + 'ONRAMP_AND_OFFRAMP', +]); + +const CHARGE_BEARERS = new Set([ + 'DEBT', + 'CRED', + 'SHAR', + 'SLEV', +]); + +const INSTRUCTION_STATUSES = new Set([ + 'PENDING', + 'QUOTED', + 'BROADCAST', + 'CONFIRMING', + 'FINAL', + 'FAILED', + 'CANCELLED', + 'EXPIRED', + 'SLIPPAGE_EXCEEDED', + 'RAMP_FAILED', +]); + +const REPORT_QUERY_TYPES = new Set([ + 'BALANCE', + 'INTRADAY', + 'STATEMENT', + 'NOTIFICATION_SUBSCRIBE', + 'NOTIFICATION_UNSUBSCRIBE', +]); + +const REPORT_ENTRY_STATUSES = new Set([ + 'BOOK', + 'PDNG', +]); + +const REPORT_CREDIT_DEBIT_INDICATORS = new Set([ + 'CRDT', + 'DBIT', +]); + +const REPORT_SEARCH_SORTS = new Set([ + 'booking_date_asc', + 'booking_date_desc', + 'amount_asc', + 'amount_desc', +]); + +const REPORT_STATS_GROUP_BY = new Set([ + 'entry_status', + 'finality_status', + 'credit_debit', + 'token', + 'day', + 'counterparty_wallet', +]); + +const INVESTIGATION_CASE_TYPES = new Set([ + 'STATUS_QUERY', + 'BENEFICIARY_CREDIT_QUERY', + 'TRAVEL_RULE_DISPUTE', + 'RETURN_REQUEST', + 'SETTLEMENT_DISCREPANCY', +]); + +const INVESTIGATION_CASE_STATUSES = new Set([ + 'OPEN', + 'IN_PROGRESS', + 'WAITING_COUNTERPARTY', + 'RESOLVED', + 'CLOSED', +]); + +const INVESTIGATION_PRIORITIES = new Set([ + 'LOW', + 'NORMAL', + 'HIGH', + 'URGENT', +]); + +const INVESTIGATION_RESOLUTION_TYPES = new Set([ + 'INFORMATION_PROVIDED', + 'NO_ISSUE_FOUND', + 'RETURN_INITIATED', + 'MANUAL_REMEDIATION', + 'REJECTED', +]); + +const RETURN_CASE_TYPES = new Set([ + 'CUSTOMER_REFUND', + 'BENEFICIARY_REJECTED', + 'DUPLICATE_PAYMENT', + 'SETTLEMENT_CORRECTION', + 'COMPLIANCE_REMEDIATION', +]); + +const RETURN_METHODS = new Set([ + 'ON_CHAIN_COMPENSATING_TRANSFER', + 'OFF_CHAIN_REFUND', + 'MANUAL_FIAT_REMEDIATION', +]); + +const RETURN_CASE_STATUSES = new Set([ + 'PROPOSED', + 'APPROVED', + 'SETTLED', + 'DECLINED', + 'CANCELLED', +]); + +const TOM_RETURN_REASON_CODES = new Set([ + 'CANC', + 'AC01', + 'AC04', + 'AM05', + 'BE04', + 'RC03', + 'RR04', + 'FRAD', + 'TECH', + 'NARR', +]); + +const TOM_REVERSAL_REASON_CODES = new Set([ + 'DUPL', + 'FRAD', + 'TECH', + 'CUST', + 'UPAY', + 'CANC', + 'NARR', +]); + +const STATS_DIRECTIONS = new Set([ + 'OUTGOING', + 'INCOMING', + 'BOTH', +]); + +const STATS_GROUP_BY = new Set([ + 'status', + 'callback_status', + 'submission_timing', + 'chain', + 'token', + 'wallet_type', + 'counterparty_vasp', + 'rejection_reason_code', +]); + +const TRAVEL_RULE_SEARCH_SORTS = new Set([ + 'submitted_at_asc', + 'submitted_at_desc', + 'amount_asc', + 'amount_desc', +]); + +const CALLBACK_REJECTION_CODES = new Set([ + 'MISSING_MANDATORY_FIELD', + 'MISSING_CONDITIONAL_FIELD', + 'INVALID_FORMAT', + 'UNRESOLVABLE_IDENTIFIER', + 'INCONSISTENT_DATA', + 'INSUFFICIENT_IDENTIFICATION', + 'UNSUPPORTED_VALUE', +]); + +function pushError(errors, field, message) { + errors.push({ field, message }); +} + +function parseQueryList(value) { + if (Array.isArray(value)) { + return value + .flatMap((item) => String(item).split(',')) + .map((item) => item.trim()) + .filter(Boolean); + } + + if (typeof value === 'string') { + return value + .split(',') + .map((item) => item.trim()) + .filter(Boolean); + } + + return []; +} + +function validateRequiredField(errors, condition, field, message) { + if (!condition) { + pushError(errors, field, message); + } +} + +function validateEnumField(errors, value, allowedValues, field, label) { + if (value === undefined || value === null) { + return; + } + + if (!allowedValues.has(value)) { + pushError( + errors, + field, + `${label} must be one of: ${Array.from(allowedValues).join(', ')}.`, + ); + } +} + +function validatePatternField(errors, value, pattern, field, message) { + if (value === undefined || value === null || value === '') { + return; + } + + if (!pattern.test(value)) { + pushError(errors, field, message); + } +} + +function validateDateTimeField(errors, value, field, required = false) { + if (value === undefined || value === null || value === '') { + if (required) { + pushError(errors, field, `${field} is required.`); + } + return; + } + + if (!isIsoDateTime(value)) { + pushError(errors, field, `${field} must be an ISO 8601 date-time string.`); + } +} + +function validateDateField(errors, value, field, required = false) { + if (value === undefined || value === null || value === '') { + if (required) { + pushError(errors, field, `${field} is required.`); + } + return; + } + + if (!/^\d{4}-\d{2}-\d{2}$/.test(String(value))) { + pushError(errors, field, `${field} must be an ISO 8601 date string.`); + return; + } + + const parsed = Date.parse(`${value}T00:00:00Z`); + if (Number.isNaN(parsed)) { + pushError(errors, field, `${field} must be an ISO 8601 date string.`); + } +} + +function validateUuidField(errors, value, field) { + if (value === undefined || value === null || value === '') { + return; + } + + if (!isUuid(value)) { + pushError(errors, field, `${field} must be a UUID.`); + } +} + +function validateUuidArrayField(errors, value, field) { + if (value === undefined || value === null) { + return; + } + + if (!Array.isArray(value)) { + pushError(errors, field, `${field} must be an array of UUIDs.`); + return; + } + + value.forEach((entry, index) => { + validateUuidField(errors, entry, `${field}[${index}]`); + }); +} + +function validateTextField(errors, value, field, message) { + validateRequiredField(errors, hasText(value), field, message); +} + +function validateEnumListField(errors, value, allowedValues, field, label) { + if (value === undefined || value === null || value === '') { + return; + } + + const values = parseQueryList(value); + if (values.length === 0) { + pushError(errors, field, `${field} must contain at least one value.`); + return; + } + + for (const entry of values) { + if (!allowedValues.has(entry)) { + pushError( + errors, + field, + `${label} must contain only: ${Array.from(allowedValues).join(', ')}.`, + ); + return; + } + } +} + +function validateIntegerRangeField(errors, value, field, { min, max }) { + if (value === undefined || value === null || value === '') { + return; + } + + const raw = String(value); + if (!/^\d+$/.test(raw)) { + pushError(errors, field, `${field} must be an integer.`); + return; + } + + const parsed = Number.parseInt(raw, 10); + if (parsed < min || parsed > max) { + pushError(errors, field, `${field} must be between ${min} and ${max}.`); + } +} + +function validateDecimalField(errors, value, field) { + if (value === undefined || value === null || value === '') { + return; + } + + if (!/^\d+(\.\d+)?$/.test(String(value))) { + pushError(errors, field, `${field} must be a positive decimal number.`); + } +} + +function validateCursorField(errors, value, field) { + if (value === undefined || value === null || value === '') { + return; + } + + if (!hasText(value)) { + pushError(errors, field, `${field} must be a non-empty string.`); + } +} + +function validateBooleanField(errors, value, field) { + if (value === undefined || value === null) { + return; + } + + if (typeof value !== 'boolean') { + pushError(errors, field, `${field} must be a boolean.`); + } +} + +function validateBooleanQueryField(errors, value, field) { + if (value === undefined || value === null || value === '') { + return; + } + + const normalized = String(value).trim().toLowerCase(); + if (!['true', 'false', '1', '0', 'yes', 'no', 'on', 'off'].includes(normalized)) { + pushError(errors, field, `${field} must be a boolean value.`); + } +} + +function validateDateRange(errors, fromValue, toValue, fromField, toField) { + if (!isIsoDateTime(fromValue) || !isIsoDateTime(toValue)) { + return; + } + + if (Date.parse(fromValue) > Date.parse(toValue)) { + pushError(errors, fromField, `${fromField} must be earlier than or equal to ${toField}.`); + } +} + +function validateParty(errors, value, field) { + validateRequiredField( + errors, + isObject(value), + field, + `${field} is required and must be an object.`, + ); + if (!isObject(value)) { + return; + } + + validateTextField(errors, value.name, `${field}.name`, `${field}.name is required.`); + validatePatternField( + errors, + value.lei, + LEI_PATTERN, + `${field}.lei`, + `${field}.lei must be a valid LEI.`, + ); + validatePatternField( + errors, + value.country, + COUNTRY_CODE_PATTERN, + `${field}.country`, + `${field}.country must be an ISO 3166-1 alpha-2 country code.`, + ); +} + +function validateAgent(errors, value, field) { + validateRequiredField( + errors, + isObject(value), + field, + `${field} is required and must be an object.`, + ); + if (!isObject(value)) { + return; + } + + validateRequiredField( + errors, + hasValidTopLevelOrNestedName(value), + `${field}.name`, + `${field}.name is required.`, + ); + validatePatternField( + errors, + getTopLevelOrNestedLei(value), + LEI_PATTERN, + `${field}.lei`, + `${field}.lei must be a valid LEI.`, + ); + validatePatternField( + errors, + getTopLevelOrNestedBic(value), + /^[A-Z0-9]{4}[A-Z]{2}[A-Z0-9]{2}([A-Z0-9]{3})?$/, + `${field}.bic`, + `${field}.bic must be a valid BIC.`, + ); +} + +function validateWalletAccount(errors, value, field) { + validateRequiredField( + errors, + isObject(value), + field, + `${field} is required and must be an object.`, + ); + if (!isObject(value)) { + return; + } + + validateTextField( + errors, + value.proxy?.identification, + `${field}.proxy.identification`, + `${field}.proxy.identification is required.`, + ); +} + +function validateNamedReference(errors, value, field, { required = false } = {}) { + if (value === undefined || value === null) { + if (required) { + pushError(errors, field, `${field} is required and must be an object.`); + } + return; + } + + validateRequiredField( + errors, + isObject(value), + field, + `${field} must be an object.`, + ); + if (!isObject(value)) { + return; + } + + validateTextField(errors, value.name, `${field}.name`, `${field}.name is required.`); + validatePatternField( + errors, + value.lei, + LEI_PATTERN, + `${field}.lei`, + `${field}.lei must be a valid LEI.`, + ); +} + +function validateAmountObject(errors, value, field) { + validateRequiredField( + errors, + isObject(value), + field, + `${field} is required and must be an object.`, + ); + if (!isObject(value)) { + return; + } + + validateRequiredField( + errors, + isDecimalString(value.amount), + `${field}.amount`, + `${field}.amount is required and must be a decimal string.`, + ); + validateTextField( + errors, + value.currency, + `${field}.currency`, + `${field}.currency is required.`, + ); +} + +function validateTokenIdentification(errors, value, field) { + validateRequiredField( + errors, + isObject(value), + field, + `${field} is required and must be an object.`, + ); + if (!isObject(value)) { + return; + } + + const hasIdentifier = + hasText(value.token_dti) || + hasText(value.contract_address) || + hasText(value.isin) || + hasText(value.token_symbol); + validateRequiredField( + errors, + hasIdentifier, + field, + `${field} must include at least one token identifier.`, + ); + validatePatternField( + errors, + value.token_dti, + TOKEN_DTI_PATTERN, + `${field}.token_dti`, + `${field}.token_dti must be a 9-character uppercase DTI value.`, + ); + validatePatternField( + errors, + value.chain_dli, + CHAIN_DLI_PATTERN, + `${field}.chain_dli`, + `${field}.chain_dli must be a 9-character uppercase DLI value.`, + ); +} + +function validateRejectionReasons(errors, reasons, field) { + if (!Array.isArray(reasons) || reasons.length === 0) { + pushError( + errors, + field, + `${field} is required and must contain at least one rejection reason.`, + ); + return; + } + + reasons.forEach((reason, index) => { + const baseField = `${field}[${index}]`; + validateRequiredField( + errors, + isObject(reason), + baseField, + `${baseField} must be an object.`, + ); + if (!isObject(reason)) { + return; + } + validateEnumField( + errors, + reason.code, + CALLBACK_REJECTION_CODES, + `${baseField}.code`, + `${baseField}.code`, + ); + }); +} + +function validateUriField(errors, value, field, { required = false } = {}) { + if (value === undefined || value === null || value === '') { + if (required) { + pushError(errors, field, `${field} is required.`); + } + return; + } + + if (!hasText(value)) { + pushError(errors, field, `${field} must be a non-empty URI string.`); + return; + } + + try { + const parsed = new URL(value); + if (!['http:', 'https:'].includes(parsed.protocol)) { + pushError(errors, field, `${field} must be an http or https URI.`); + } + } catch { + pushError(errors, field, `${field} must be a valid URI.`); + } +} + +function extractChainDliFromAccount(account) { + const proprietary = account?.type?.proprietary; + if (!hasText(proprietary)) { + return null; + } + + const normalized = proprietary.trim(); + return normalized.startsWith('DLID/') ? normalized.slice(5) : normalized; +} + +function validateReportAccount(errors, value, field) { + validateRequiredField( + errors, + isObject(value), + field, + `${field} is required and must be an object.`, + ); + if (!isObject(value)) { + return; + } + + validateTextField( + errors, + value.identification?.proxy?.identification, + `${field}.identification.proxy.identification`, + `${field}.identification.proxy.identification is required.`, + ); + validateTextField( + errors, + value.type?.proprietary, + `${field}.type.proprietary`, + `${field}.type.proprietary is required.`, + ); + validatePatternField( + errors, + extractChainDliFromAccount(value), + CHAIN_DLI_PATTERN, + `${field}.type.proprietary`, + `${field}.type.proprietary must contain a valid DLID/ or bare DLI value.`, + ); +} + +export function formatValidationErrors(errors) { + return errors.map(({ field, message }) => ({ + field, + issue: message, + })); +} + +export function validateQuoteRequest(body) { + const errors = []; + validateRequiredField(errors, isObject(body), 'body', 'Request body must be a JSON object.'); + if (errors.length) { + return errors; + } + + validateTokenIdentification(errors, body.token, 'token'); + validateRequiredField( + errors, + hasText(body.chain_dli), + 'chain_dli', + 'chain_dli is required.', + ); + validatePatternField( + errors, + body.chain_dli, + CHAIN_DLI_PATTERN, + 'chain_dli', + 'chain_dli must be a 9-character uppercase DLI value.', + ); + validateRequiredField( + errors, + isDecimalString(body.amount), + 'amount', + 'amount is required and must be a decimal string.', + ); + validateTextField(errors, body.currency, 'currency', 'currency is required.'); + validateTextField( + errors, + body.custody_model, + 'custody_model', + 'custody_model is required.', + ); + validateEnumField( + errors, + body.custody_model, + CUSTODY_MODELS, + 'custody_model', + 'custody_model', + ); + validateEnumField( + errors, + body.ramp_type, + RAMP_TYPES, + 'ramp_type', + 'ramp_type', + ); + validateDateTimeField( + errors, + body.requested_execution_time, + 'requested_execution_time', + ); + return errors; +} + +export function validateInstructionSubmission(body) { + const errors = []; + validateRequiredField(errors, isObject(body), 'body', 'Request body must be a JSON object.'); + if (errors.length) { + return errors; + } + + validateRequiredField( + errors, + isObject(body.payment_identification), + 'payment_identification', + 'payment_identification is required.', + ); + validateTextField( + errors, + body.payment_identification?.end_to_end_identification, + 'payment_identification.end_to_end_identification', + 'payment_identification.end_to_end_identification is required.', + ); + validateUuidField( + errors, + body.payment_identification?.uetr, + 'payment_identification.uetr', + ); + validateUuidField( + errors, + body.payment_identification?.quote_id, + 'payment_identification.quote_id', + ); + + validateTextField( + errors, + body.charge_bearer, + 'charge_bearer', + 'charge_bearer is required.', + ); + validateEnumField( + errors, + body.charge_bearer, + CHARGE_BEARERS, + 'charge_bearer', + 'charge_bearer', + ); + + validateAmountObject( + errors, + body.interbank_settlement_amount, + 'interbank_settlement_amount', + ); + validateParty(errors, body.debtor, 'debtor'); + validateAgent(errors, body.debtor_agent, 'debtor_agent'); + validateParty(errors, body.creditor, 'creditor'); + validateAgent(errors, body.creditor_agent, 'creditor_agent'); + + validateRequiredField( + errors, + isObject(body.blockchain_instruction), + 'blockchain_instruction', + 'blockchain_instruction is required.', + ); + if (isObject(body.blockchain_instruction)) { + validateTokenIdentification( + errors, + body.blockchain_instruction.token, + 'blockchain_instruction.token', + ); + validateTextField( + errors, + body.blockchain_instruction.chain_dli, + 'blockchain_instruction.chain_dli', + 'blockchain_instruction.chain_dli is required.', + ); + validatePatternField( + errors, + body.blockchain_instruction.chain_dli, + CHAIN_DLI_PATTERN, + 'blockchain_instruction.chain_dli', + 'blockchain_instruction.chain_dli must be a 9-character uppercase DLI value.', + ); + validateTextField( + errors, + body.blockchain_instruction.custody_model, + 'blockchain_instruction.custody_model', + 'blockchain_instruction.custody_model is required.', + ); + validateEnumField( + errors, + body.blockchain_instruction.custody_model, + CUSTODY_MODELS, + 'blockchain_instruction.custody_model', + 'blockchain_instruction.custody_model', + ); + validateEnumField( + errors, + body.blockchain_instruction.ramp_instruction?.ramp_type, + RAMP_TYPES, + 'blockchain_instruction.ramp_instruction.ramp_type', + 'blockchain_instruction.ramp_instruction.ramp_type', + ); + validatePatternField( + errors, + body.blockchain_instruction.maximum_slippage_rate, + MAX_SLIPPAGE_PATTERN, + 'blockchain_instruction.maximum_slippage_rate', + 'blockchain_instruction.maximum_slippage_rate must be a decimal fraction.', + ); + } + + validateUuidField( + errors, + body.travel_rule_record_id, + 'travel_rule_record_id', + ); + validateDateTimeField(errors, body.expiry_date_time, 'expiry_date_time'); + + return errors; +} + +export function validateTravelRuleSubmission(body) { + const errors = []; + validateRequiredField(errors, isObject(body), 'body', 'Request body must be a JSON object.'); + if (errors.length) { + return errors; + } + + validateEnumField( + errors, + body.submission_timing, + TRAVEL_RULE_SUBMISSION_TIMINGS, + 'submission_timing', + 'submission_timing', + ); + validateDateTimeField( + errors, + body.correction_of_callback_ref?.callback_timestamp, + 'correction_of_callback_ref.callback_timestamp', + ); + + const data = body.travel_rule_data; + validateRequiredField(errors, isObject(data), 'travel_rule_data', 'travel_rule_data is required.'); + if (!isObject(data)) { + return errors; + } + + validateRequiredField( + errors, + isObject(data.payment_identification), + 'travel_rule_data.payment_identification', + 'travel_rule_data.payment_identification is required.', + ); + validateTextField( + errors, + data.payment_identification?.end_to_end_identification, + 'travel_rule_data.payment_identification.end_to_end_identification', + 'travel_rule_data.payment_identification.end_to_end_identification is required.', + ); + + validateAmountObject( + errors, + data.interbank_settlement_amount, + 'travel_rule_data.interbank_settlement_amount', + ); + validateTextField( + errors, + data.charge_bearer, + 'travel_rule_data.charge_bearer', + 'travel_rule_data.charge_bearer is required.', + ); + validateEnumField( + errors, + data.charge_bearer, + CHARGE_BEARERS, + 'travel_rule_data.charge_bearer', + 'travel_rule_data.charge_bearer', + ); + validateParty(errors, data.debtor, 'travel_rule_data.debtor'); + validateWalletAccount( + errors, + data.debtor_account, + 'travel_rule_data.debtor_account', + ); + validateAgent(errors, data.debtor_agent, 'travel_rule_data.debtor_agent'); + validateParty(errors, data.creditor, 'travel_rule_data.creditor'); + validateWalletAccount( + errors, + data.creditor_account, + 'travel_rule_data.creditor_account', + ); + validateAgent(errors, data.creditor_agent, 'travel_rule_data.creditor_agent'); + validateEnumField( + errors, + data.counterparty_wallet_type, + TRAVEL_RULE_WALLET_TYPES, + 'travel_rule_data.counterparty_wallet_type', + 'travel_rule_data.counterparty_wallet_type', + ); + + return errors; +} + +export function validateTravelRuleCallback(body) { + const errors = []; + validateRequiredField(errors, isObject(body), 'body', 'Request body must be a JSON object.'); + if (errors.length) { + return errors; + } + + validateTextField( + errors, + body.callback_status, + 'callback_status', + 'callback_status is required.', + ); + validateEnumField( + errors, + body.callback_status, + TRAVEL_RULE_CALLBACK_STATUSES, + 'callback_status', + 'callback_status', + ); + validateAgent(errors, body.receiving_vasp, 'receiving_vasp'); + validateDateTimeField( + errors, + body.callback_timestamp, + 'callback_timestamp', + true, + ); + + if (body.callback_status === 'REJECTED') { + validateRejectionReasons(errors, body.rejection_reasons, 'rejection_reasons'); + } + + return errors; +} + +export function validateTravelRuleStatsQuery(query) { + const errors = []; + validateDateTimeField(errors, query.submitted_from, 'submitted_from', true); + validateDateTimeField(errors, query.submitted_to, 'submitted_to', true); + validateDateRange( + errors, + query.submitted_from, + query.submitted_to, + 'submitted_from', + 'submitted_to', + ); + validateEnumField( + errors, + query.direction, + STATS_DIRECTIONS, + 'direction', + 'direction', + ); + validateEnumField( + errors, + query.group_by, + STATS_GROUP_BY, + 'group_by', + 'group_by', + ); + return errors; +} + +export function validateTravelRuleSearchQuery(query) { + const errors = []; + validateEnumField( + errors, + query.direction, + STATS_DIRECTIONS, + 'direction', + 'direction', + ); + validateDateTimeField(errors, query.submitted_from, 'submitted_from'); + validateDateTimeField(errors, query.submitted_to, 'submitted_to'); + validateDateRange( + errors, + query.submitted_from, + query.submitted_to, + 'submitted_from', + 'submitted_to', + ); + validateEnumListField( + errors, + query.status, + TRAVEL_RULE_RECORD_STATUSES, + 'status', + 'status', + ); + validateEnumListField( + errors, + query.callback_status, + TRAVEL_RULE_CALLBACK_FILTER_STATUSES, + 'callback_status', + 'callback_status', + ); + validateEnumField( + errors, + query.submission_timing, + TRAVEL_RULE_SUBMISSION_TIMINGS, + 'submission_timing', + 'submission_timing', + ); + validatePatternField( + errors, + query.currency, + CURRENCY_CODE_PATTERN, + 'currency', + 'currency must be a 3-letter uppercase ISO 4217 code.', + ); + validatePatternField( + errors, + query.counterparty_vasp_lei, + LEI_PATTERN, + 'counterparty_vasp_lei', + 'counterparty_vasp_lei must be a valid LEI.', + ); + validateEnumField( + errors, + query.wallet_type, + TRAVEL_RULE_WALLET_TYPES, + 'wallet_type', + 'wallet_type', + ); + validateDecimalField(errors, query.amount_min, 'amount_min'); + validateDecimalField(errors, query.amount_max, 'amount_max'); + validateIntegerRangeField(errors, query.page_size, 'page_size', { min: 1, max: 200 }); + validateCursorField(errors, query.after, 'after'); + validateEnumField( + errors, + query.sort, + TRAVEL_RULE_SEARCH_SORTS, + 'sort', + 'sort', + ); + return errors; +} + +export function validateInstructionSearchQuery(query) { + const errors = []; + validateDateTimeField(errors, query.from, 'from'); + validateDateTimeField(errors, query.to, 'to'); + validateDateRange(errors, query.from, query.to, 'from', 'to'); + validateEnumListField( + errors, + query.status, + INSTRUCTION_STATUSES, + 'status', + 'status', + ); + validatePatternField( + errors, + query.chain_dli, + CHAIN_DLI_PATTERN, + 'chain_dli', + 'chain_dli must be a 9-character uppercase DLI value.', + ); + validatePatternField( + errors, + query.token_dti, + TOKEN_DTI_PATTERN, + 'token_dti', + 'token_dti must be a 9-character uppercase DTI value.', + ); + validateIntegerRangeField(errors, query.page_size, 'page_size', { min: 1, max: 500 }); + validateCursorField(errors, query.cursor, 'cursor'); + return errors; +} + +export function validateReportQuery(body) { + const errors = []; + validateRequiredField(errors, isObject(body), 'body', 'Request body must be a JSON object.'); + if (errors.length) { + return errors; + } + + validateTextField( + errors, + body.query_identification, + 'query_identification', + 'query_identification is required.', + ); + validateTextField(errors, body.query_type, 'query_type', 'query_type is required.'); + validateEnumField(errors, body.query_type, REPORT_QUERY_TYPES, 'query_type', 'query_type'); + validateReportAccount(errors, body.account, 'account'); + + if (body.token_filter !== undefined) { + if (!Array.isArray(body.token_filter) || body.token_filter.length === 0) { + pushError( + errors, + 'token_filter', + 'token_filter must be a non-empty array when provided.', + ); + } else { + body.token_filter.forEach((token, index) => { + validateTokenIdentification(errors, token, `token_filter[${index}]`); + }); + } + } + + if (body.reporting_period !== undefined) { + validateRequiredField( + errors, + isObject(body.reporting_period), + 'reporting_period', + 'reporting_period must be an object.', + ); + if (isObject(body.reporting_period)) { + validateDateTimeField( + errors, + body.reporting_period.from_date_time, + 'reporting_period.from_date_time', + ); + validateDateTimeField( + errors, + body.reporting_period.to_date_time, + 'reporting_period.to_date_time', + ); + validateDateRange( + errors, + body.reporting_period.from_date_time, + body.reporting_period.to_date_time, + 'reporting_period.from_date_time', + 'reporting_period.to_date_time', + ); + } + } + + if (body.entry_status_filter !== undefined) { + if (!Array.isArray(body.entry_status_filter) || body.entry_status_filter.length === 0) { + pushError( + errors, + 'entry_status_filter', + 'entry_status_filter must be a non-empty array when provided.', + ); + } else { + body.entry_status_filter.forEach((status) => { + if (!REPORT_ENTRY_STATUSES.has(status)) { + pushError( + errors, + 'entry_status_filter', + `entry_status_filter must contain only: ${Array.from(REPORT_ENTRY_STATUSES).join(', ')}.`, + ); + } + }); + } + } + + validateUriField(errors, body.callback_url, 'callback_url'); + validateUuidField(errors, body.subscription_id, 'subscription_id'); + + if (body.query_type === 'NOTIFICATION_SUBSCRIBE') { + validateUriField(errors, body.callback_url, 'callback_url', { required: true }); + } + + if (body.query_type === 'NOTIFICATION_UNSUBSCRIBE') { + if (!body.subscription_id) { + pushError( + errors, + 'subscription_id', + 'subscription_id is required for NOTIFICATION_UNSUBSCRIBE.', + ); + } + } + + return errors; +} + +export function validateReportIntradayQuery(query) { + const errors = []; + validateTextField(errors, query.wallet_address, 'wallet_address', 'wallet_address is required.'); + validateTextField(errors, query.chain_dli, 'chain_dli', 'chain_dli is required.'); + validatePatternField( + errors, + query.chain_dli, + CHAIN_DLI_PATTERN, + 'chain_dli', + 'chain_dli must be a 9-character uppercase DLI value.', + ); + validateDateTimeField(errors, query.from_date_time, 'from_date_time'); + validateDateTimeField(errors, query.to_date_time, 'to_date_time'); + validateDateRange(errors, query.from_date_time, query.to_date_time, 'from_date_time', 'to_date_time'); + validatePatternField( + errors, + query.token_dti, + TOKEN_DTI_PATTERN, + 'token_dti', + 'token_dti must be a 9-character uppercase DTI value.', + ); + validateEnumListField( + errors, + query.entry_status, + REPORT_ENTRY_STATUSES, + 'entry_status', + 'entry_status', + ); + validateEnumField( + errors, + query.credit_debit_indicator, + REPORT_CREDIT_DEBIT_INDICATORS, + 'credit_debit_indicator', + 'credit_debit_indicator', + ); + validateIntegerRangeField(errors, query.page_size, 'page_size', { min: 1, max: 200 }); + validateCursorField(errors, query.after, 'after'); + validateEnumField(errors, query.sort, REPORT_SEARCH_SORTS, 'sort', 'sort'); + return errors; +} + +export function validateReportStatementQuery(query) { + const errors = []; + validateTextField(errors, query.wallet_address, 'wallet_address', 'wallet_address is required.'); + validateTextField(errors, query.chain_dli, 'chain_dli', 'chain_dli is required.'); + validatePatternField( + errors, + query.chain_dli, + CHAIN_DLI_PATTERN, + 'chain_dli', + 'chain_dli must be a 9-character uppercase DLI value.', + ); + validateDateField(errors, query.from_date, 'from_date', true); + validateDateField(errors, query.to_date, 'to_date', true); + if ( + /^\d{4}-\d{2}-\d{2}$/.test(String(query.from_date ?? '')) && + /^\d{4}-\d{2}-\d{2}$/.test(String(query.to_date ?? '')) + ) { + if (Date.parse(`${query.from_date}T00:00:00Z`) > Date.parse(`${query.to_date}T00:00:00Z`)) { + pushError(errors, 'from_date', 'from_date must be earlier than or equal to to_date.'); + } + } + validatePatternField( + errors, + query.token_dti, + TOKEN_DTI_PATTERN, + 'token_dti', + 'token_dti must be a 9-character uppercase DTI value.', + ); + validateIntegerRangeField(errors, query.page_size, 'page_size', { min: 1, max: 200 }); + validateCursorField(errors, query.after, 'after'); + validateEnumField(errors, query.sort, REPORT_SEARCH_SORTS, 'sort', 'sort'); + return errors; +} + +export function validateReportSearchQuery(query) { + const errors = []; + validateTextField(errors, query.wallet_address, 'wallet_address', 'wallet_address is required.'); + validateTextField(errors, query.chain_dli, 'chain_dli', 'chain_dli is required.'); + validatePatternField( + errors, + query.chain_dli, + CHAIN_DLI_PATTERN, + 'chain_dli', + 'chain_dli must be a 9-character uppercase DLI value.', + ); + validateDateTimeField(errors, query.from_date_time, 'from_date_time'); + validateDateTimeField(errors, query.to_date_time, 'to_date_time'); + validateDateRange(errors, query.from_date_time, query.to_date_time, 'from_date_time', 'to_date_time'); + validatePatternField( + errors, + query.token_dti, + TOKEN_DTI_PATTERN, + 'token_dti', + 'token_dti must be a 9-character uppercase DTI value.', + ); + validateEnumListField( + errors, + query.entry_status, + REPORT_ENTRY_STATUSES, + 'entry_status', + 'entry_status', + ); + validateEnumField( + errors, + query.finality_status, + new Set(['PENDING', 'PROBABILISTIC', 'FINAL']), + 'finality_status', + 'finality_status', + ); + validateEnumField( + errors, + query.credit_debit_indicator, + REPORT_CREDIT_DEBIT_INDICATORS, + 'credit_debit_indicator', + 'credit_debit_indicator', + ); + validateDecimalField(errors, query.amount_min, 'amount_min'); + validateDecimalField(errors, query.amount_max, 'amount_max'); + validateUuidField(errors, query.instruction_id, 'instruction_id'); + validateUuidField(errors, query.travel_rule_record_id, 'travel_rule_record_id'); + validateIntegerRangeField(errors, query.page_size, 'page_size', { min: 1, max: 200 }); + validateCursorField(errors, query.after, 'after'); + validateEnumField(errors, query.sort, REPORT_SEARCH_SORTS, 'sort', 'sort'); + return errors; +} + +export function validateReportStatsQuery(query) { + const errors = []; + validateTextField(errors, query.wallet_address, 'wallet_address', 'wallet_address is required.'); + validateTextField(errors, query.chain_dli, 'chain_dli', 'chain_dli is required.'); + validatePatternField( + errors, + query.chain_dli, + CHAIN_DLI_PATTERN, + 'chain_dli', + 'chain_dli must be a 9-character uppercase DLI value.', + ); + validateDateTimeField(errors, query.from_date_time, 'from_date_time', true); + validateDateTimeField(errors, query.to_date_time, 'to_date_time', true); + validateDateRange(errors, query.from_date_time, query.to_date_time, 'from_date_time', 'to_date_time'); + validatePatternField( + errors, + query.token_dti, + TOKEN_DTI_PATTERN, + 'token_dti', + 'token_dti must be a 9-character uppercase DTI value.', + ); + validateEnumListField( + errors, + query.entry_status, + REPORT_ENTRY_STATUSES, + 'entry_status', + 'entry_status', + ); + validateEnumField( + errors, + query.group_by, + REPORT_STATS_GROUP_BY, + 'group_by', + 'group_by', + ); + return errors; +} + +export function validateWebhookSubscriptionSubmission(body) { + const errors = []; + validateRequiredField(errors, isObject(body), 'body', 'Request body must be a JSON object.'); + if (errors.length) { + return errors; + } + + validateRequiredField(errors, hasText(body.url), 'url', 'url is required.'); + validateRequiredField( + errors, + hasText(body.signing_secret), + 'signing_secret', + 'signing_secret is required.', + ); + + if ( + body.subscribed_event_types !== undefined && + !Array.isArray(body.subscribed_event_types) + ) { + pushError( + errors, + 'subscribed_event_types', + 'subscribed_event_types must be an array when provided.', + ); + } + + if (Array.isArray(body.subscribed_event_types)) { + for (const eventType of body.subscribed_event_types) { + if (!WEBHOOK_EVENT_TYPES.has(eventType)) { + pushError( + errors, + 'subscribed_event_types', + `Unsupported event type: ${eventType}`, + ); + } + } + } + + return errors; +} + +export function validateWebhookDispatchRequest(body) { + if (body === undefined || body === null) { + return []; + } + + const errors = []; + validateRequiredField(errors, isObject(body), 'body', 'Request body must be a JSON object.'); + if (errors.length) { + return errors; + } + + if ( + body.limit !== undefined && + (!Number.isInteger(body.limit) || body.limit <= 0) + ) { + pushError(errors, 'limit', 'limit must be a positive integer when provided.'); + } + + if (body.subscription_id !== undefined && !hasText(body.subscription_id)) { + pushError( + errors, + 'subscription_id', + 'subscription_id must be a non-empty string when provided.', + ); + } + + return errors; +} + +export function validateInvestigationCaseSubmission(body) { + const errors = []; + validateRequiredField(errors, isObject(body), 'body', 'Request body must be a JSON object.'); + if (errors.length) { + return errors; + } + + validateTextField(errors, body.case_type, 'case_type', 'case_type is required.'); + validateEnumField(errors, body.case_type, INVESTIGATION_CASE_TYPES, 'case_type', 'case_type'); + validateEnumField(errors, body.priority, INVESTIGATION_PRIORITIES, 'priority', 'priority'); + validateBooleanField( + errors, + body.requires_counterparty_action, + 'requires_counterparty_action', + ); + validateUuidField(errors, body.related_instruction_id, 'related_instruction_id'); + validateUuidField(errors, body.related_uetr, 'related_uetr'); + validateRequiredField( + errors, + hasText(body.related_instruction_id) || hasText(body.related_uetr), + 'related_instruction_id', + 'related_instruction_id or related_uetr is required.', + ); + validateTextField(errors, body.reason_code, 'reason_code', 'reason_code is required.'); + validateTextField(errors, body.narrative, 'narrative', 'narrative is required.'); + validateNamedReference(errors, body.opened_by, 'opened_by'); + validateNamedReference(errors, body.current_owner, 'current_owner'); + validateNamedReference(errors, body.counterparty, 'counterparty'); + if (body.assigned_team !== undefined) { + validateTextField( + errors, + body.assigned_team, + 'assigned_team', + 'assigned_team must be a non-empty string.', + ); + } + validateDateTimeField(errors, body.next_action_due_at, 'next_action_due_at'); + validateBooleanField( + errors, + body.reporting_follow_up_required, + 'reporting_follow_up_required', + ); + if (body.counterparty_reference !== undefined) { + validateTextField( + errors, + body.counterparty_reference, + 'counterparty_reference', + 'counterparty_reference must be a non-empty string.', + ); + } + validateUuidField(errors, body.linked_return_case_id, 'linked_return_case_id'); + validateUuidArrayField( + errors, + body.affected_notification_ids, + 'affected_notification_ids', + ); + validateUuidArrayField( + errors, + body.affected_statement_ids, + 'affected_statement_ids', + ); + + return errors; +} + +export function validateInvestigationCaseUpdate(body) { + const errors = []; + validateRequiredField(errors, isObject(body), 'body', 'Request body must be a JSON object.'); + if (errors.length) { + return errors; + } + + validateEnumField( + errors, + body.case_status, + INVESTIGATION_CASE_STATUSES, + 'case_status', + 'case_status', + ); + validateEnumField(errors, body.priority, INVESTIGATION_PRIORITIES, 'priority', 'priority'); + validateBooleanField( + errors, + body.requires_counterparty_action, + 'requires_counterparty_action', + ); + validateEnumField( + errors, + body.resolution_type, + INVESTIGATION_RESOLUTION_TYPES, + 'resolution_type', + 'resolution_type', + ); + validateUuidField(errors, body.linked_return_case_id, 'linked_return_case_id'); + validateNamedReference(errors, body.current_owner, 'current_owner'); + validateNamedReference(errors, body.counterparty, 'counterparty'); + if (body.assigned_team !== undefined) { + validateTextField( + errors, + body.assigned_team, + 'assigned_team', + 'assigned_team must be a non-empty string.', + ); + } + validateDateTimeField(errors, body.next_action_due_at, 'next_action_due_at'); + validateBooleanField( + errors, + body.reporting_follow_up_required, + 'reporting_follow_up_required', + ); + if (body.counterparty_reference !== undefined) { + validateTextField( + errors, + body.counterparty_reference, + 'counterparty_reference', + 'counterparty_reference must be a non-empty string.', + ); + } + validateUuidArrayField( + errors, + body.affected_notification_ids, + 'affected_notification_ids', + ); + validateUuidArrayField( + errors, + body.affected_statement_ids, + 'affected_statement_ids', + ); + if (body.narrative !== undefined) { + validateTextField(errors, body.narrative, 'narrative', 'narrative must be a non-empty string.'); + } + if (body.resolution_summary !== undefined) { + validateTextField( + errors, + body.resolution_summary, + 'resolution_summary', + 'resolution_summary must be a non-empty string.', + ); + } + if ( + body.case_status && + ['RESOLVED', 'CLOSED'].includes(body.case_status) && + !hasText(body.resolution_summary) + ) { + pushError( + errors, + 'resolution_summary', + 'resolution_summary is required when case_status is RESOLVED or CLOSED.', + ); + } + + return errors; +} + +export function validateInvestigationCaseSearchQuery(query) { + const errors = []; + validateUuidField(errors, query.related_instruction_id, 'related_instruction_id'); + validateUuidField(errors, query.related_uetr, 'related_uetr'); + validateEnumListField( + errors, + query.case_type, + INVESTIGATION_CASE_TYPES, + 'case_type', + 'case_type', + ); + validateEnumListField( + errors, + query.case_status, + INVESTIGATION_CASE_STATUSES, + 'case_status', + 'case_status', + ); + validateEnumListField( + errors, + query.priority, + INVESTIGATION_PRIORITIES, + 'priority', + 'priority', + ); + validateBooleanQueryField( + errors, + query.requires_counterparty_action, + 'requires_counterparty_action', + ); + validateDateTimeField(errors, query.opened_from, 'opened_from'); + validateDateTimeField(errors, query.opened_to, 'opened_to'); + validateDateRange(errors, query.opened_from, query.opened_to, 'opened_from', 'opened_to'); + validateIntegerRangeField(errors, query.page_size, 'page_size', { min: 1, max: 200 }); + validateCursorField(errors, query.cursor, 'cursor'); + return errors; +} + +export function validateReturnCaseSubmission(body) { + const errors = []; + validateRequiredField(errors, isObject(body), 'body', 'Request body must be a JSON object.'); + if (errors.length) { + return errors; + } + + validateTextField(errors, body.return_type, 'return_type', 'return_type is required.'); + validateEnumField(errors, body.return_type, RETURN_CASE_TYPES, 'return_type', 'return_type'); + validateTextField(errors, body.return_method, 'return_method', 'return_method is required.'); + validateEnumField( + errors, + body.return_method, + RETURN_METHODS, + 'return_method', + 'return_method', + ); + validateUuidField(errors, body.original_instruction_id, 'original_instruction_id'); + validateUuidField(errors, body.original_uetr, 'original_uetr'); + validateRequiredField( + errors, + hasText(body.original_instruction_id) || hasText(body.original_uetr), + 'original_instruction_id', + 'original_instruction_id or original_uetr is required.', + ); + validateAmountObject(errors, body.return_amount, 'return_amount'); + if (body.return_asset !== undefined) { + validateTokenIdentification(errors, body.return_asset, 'return_asset'); + } + validateTextField(errors, body.reason_code, 'reason_code', 'reason_code is required.'); + validateTextField(errors, body.narrative, 'narrative', 'narrative is required.'); + validateNamedReference(errors, body.opened_by, 'opened_by'); + validateNamedReference(errors, body.current_owner, 'current_owner'); + validateNamedReference(errors, body.counterparty, 'counterparty'); + if (body.assigned_team !== undefined) { + validateTextField( + errors, + body.assigned_team, + 'assigned_team', + 'assigned_team must be a non-empty string.', + ); + } + validateDateTimeField(errors, body.next_action_due_at, 'next_action_due_at'); + validateBooleanField( + errors, + body.reporting_follow_up_required, + 'reporting_follow_up_required', + ); + if (body.counterparty_reference !== undefined) { + validateTextField( + errors, + body.counterparty_reference, + 'counterparty_reference', + 'counterparty_reference must be a non-empty string.', + ); + } + validateUuidField( + errors, + body.linked_investigation_case_id, + 'linked_investigation_case_id', + ); + validateUuidField( + errors, + body.compensating_instruction_id, + 'compensating_instruction_id', + ); + if (body.off_chain_reference !== undefined) { + validateTextField( + errors, + body.off_chain_reference, + 'off_chain_reference', + 'off_chain_reference must be a non-empty string.', + ); + } + validateUuidArrayField( + errors, + body.affected_notification_ids, + 'affected_notification_ids', + ); + validateUuidArrayField( + errors, + body.affected_statement_ids, + 'affected_statement_ids', + ); + + return errors; +} + +export function validateReturnCaseUpdate(body) { + const errors = []; + validateRequiredField(errors, isObject(body), 'body', 'Request body must be a JSON object.'); + if (errors.length) { + return errors; + } + + validateEnumField( + errors, + body.return_status, + RETURN_CASE_STATUSES, + 'return_status', + 'return_status', + ); + validateUuidField( + errors, + body.linked_investigation_case_id, + 'linked_investigation_case_id', + ); + validateUuidField( + errors, + body.compensating_instruction_id, + 'compensating_instruction_id', + ); + validateNamedReference(errors, body.current_owner, 'current_owner'); + validateNamedReference(errors, body.counterparty, 'counterparty'); + if (body.assigned_team !== undefined) { + validateTextField( + errors, + body.assigned_team, + 'assigned_team', + 'assigned_team must be a non-empty string.', + ); + } + validateDateTimeField(errors, body.next_action_due_at, 'next_action_due_at'); + validateBooleanField( + errors, + body.reporting_follow_up_required, + 'reporting_follow_up_required', + ); + if (body.counterparty_reference !== undefined) { + validateTextField( + errors, + body.counterparty_reference, + 'counterparty_reference', + 'counterparty_reference must be a non-empty string.', + ); + } + validateUuidArrayField( + errors, + body.affected_notification_ids, + 'affected_notification_ids', + ); + validateUuidArrayField( + errors, + body.affected_statement_ids, + 'affected_statement_ids', + ); + if (body.narrative !== undefined) { + validateTextField(errors, body.narrative, 'narrative', 'narrative must be a non-empty string.'); + } + if (body.resolution_summary !== undefined) { + validateTextField( + errors, + body.resolution_summary, + 'resolution_summary', + 'resolution_summary must be a non-empty string.', + ); + } + if (body.off_chain_reference !== undefined) { + validateTextField( + errors, + body.off_chain_reference, + 'off_chain_reference', + 'off_chain_reference must be a non-empty string.', + ); + } + + return errors; +} + +export function validateReturnCaseSearchQuery(query) { + const errors = []; + validateUuidField(errors, query.original_instruction_id, 'original_instruction_id'); + validateUuidField(errors, query.original_uetr, 'original_uetr'); + validateUuidField( + errors, + query.linked_investigation_case_id, + 'linked_investigation_case_id', + ); + validateEnumListField( + errors, + query.return_type, + RETURN_CASE_TYPES, + 'return_type', + 'return_type', + ); + validateEnumListField( + errors, + query.return_method, + RETURN_METHODS, + 'return_method', + 'return_method', + ); + validateEnumListField( + errors, + query.return_status, + RETURN_CASE_STATUSES, + 'return_status', + 'return_status', + ); + validateDateTimeField(errors, query.opened_from, 'opened_from'); + validateDateTimeField(errors, query.opened_to, 'opened_to'); + validateDateRange(errors, query.opened_from, query.opened_to, 'opened_from', 'opened_to'); + validateIntegerRangeField(errors, query.page_size, 'page_size', { min: 1, max: 200 }); + validateCursorField(errors, query.cursor, 'cursor'); + return errors; +} + +function validateTomAmountObject(errors, value, field) { + validateRequiredField( + errors, + isObject(value), + field, + `${field} is required and must be an object.`, + ); + if (!isObject(value)) { + return; + } + + if (typeof value.amount !== 'string' || value.amount.length === 0) { + pushError(errors, `${field}.amount`, `${field}.amount is required and must be a string.`); + } else if (value.amount.length > 30) { + pushError(errors, `${field}.amount`, `${field}.amount must be at most 30 characters.`); + } else if (!/^(0|[1-9]\d*)(\.\d+)?$/.test(value.amount)) { + pushError(errors, `${field}.amount`, `${field}.amount must be a decimal string.`); + } + + if (typeof value.currency !== 'string' || value.currency.length === 0) { + pushError( + errors, + `${field}.currency`, + `${field}.currency is required and must be a string.`, + ); + } else if (value.currency.length > 20) { + pushError( + errors, + `${field}.currency`, + `${field}.currency must be at most 20 characters.`, + ); + } +} + +function validateTomReasonObject(errors, value, field, allowedCodes) { + validateRequiredField( + errors, + isObject(value), + field, + `${field} is required and must be an object.`, + ); + if (!isObject(value)) { + return; + } + + if (!hasText(value.code)) { + pushError(errors, `${field}.code`, `${field}.code is required.`); + } else if (!allowedCodes.has(value.code)) { + pushError( + errors, + `${field}.code`, + `${field}.code must be one of: ${Array.from(allowedCodes).join(', ')}.`, + ); + } + + const additionalInformation = value.additional_information; + const isNarr = value.code === 'NARR'; + + if (additionalInformation === undefined || additionalInformation === null) { + if (isNarr) { + pushError( + errors, + `${field}.additional_information`, + `${field}.additional_information is required when code is NARR.`, + ); + } + return; + } + + if (!Array.isArray(additionalInformation)) { + pushError( + errors, + `${field}.additional_information`, + `${field}.additional_information must be an array of strings.`, + ); + return; + } + + if (additionalInformation.length < 1 || additionalInformation.length > 5) { + pushError( + errors, + `${field}.additional_information`, + `${field}.additional_information must contain between 1 and 5 entries.`, + ); + } + + additionalInformation.forEach((entry, index) => { + if (typeof entry !== 'string' || entry.length === 0) { + pushError( + errors, + `${field}.additional_information[${index}]`, + `${field}.additional_information[${index}] must be a non-empty string.`, + ); + return; + } + if (entry.length > 105) { + pushError( + errors, + `${field}.additional_information[${index}]`, + `${field}.additional_information[${index}] must be at most 105 characters.`, + ); + } + }); +} + +function validateTomOptionalString(errors, value, field, maxLength) { + if (value === undefined || value === null) { + return; + } + if (typeof value !== 'string' || value.length === 0) { + pushError(errors, field, `${field} must be a non-empty string when provided.`); + return; + } + if (value.length > maxLength) { + pushError(errors, field, `${field} must be at most ${maxLength} characters.`); + } +} + +function validateTomOptionalAmountObject(errors, value, field) { + if (value === undefined || value === null) { + return; + } + validateTomAmountObject(errors, value, field); +} + +function validateTomWebhookUrl(errors, value, field) { + if (value === undefined || value === null) { + return; + } + + if (typeof value !== 'string' || value.length === 0) { + pushError(errors, field, `${field} must be a non-empty string when provided.`); + return; + } + + if (value.length > 500) { + pushError(errors, field, `${field} must be at most 500 characters.`); + return; + } + + try { + const parsed = new URL(value); + if (!['http:', 'https:'].includes(parsed.protocol)) { + pushError(errors, field, `${field} must be an http or https URI.`); + } + } catch { + pushError(errors, field, `${field} must be a valid URI.`); + } +} + +export function validateTomReturnRequest(body) { + const errors = []; + validateRequiredField(errors, isObject(body), 'body', 'Request body must be a JSON object.'); + if (errors.length) { + return errors; + } + + validateTomReasonObject( + errors, + body.return_reason, + 'return_reason', + TOM_RETURN_REASON_CODES, + ); + validateTomAmountObject(errors, body.returned_amount, 'returned_amount'); + validateTomOptionalAmountObject(errors, body.compensation_amount, 'compensation_amount'); + validateTomOptionalString(errors, body.return_identification, 'return_identification', 35); + validateTomOptionalString(errors, body.instruction_for_creditor, 'instruction_for_creditor', 210); + if (body.webhook_url !== undefined && body.webhook_url !== null) { + pushError( + errors, + 'webhook_url', + 'webhook_url is only accepted on ReversalRequest.', + ); + } + return errors; +} + +export function validateTomReversalRequest(body) { + const errors = []; + validateRequiredField(errors, isObject(body), 'body', 'Request body must be a JSON object.'); + if (errors.length) { + return errors; + } + + validateTomReasonObject( + errors, + body.reversal_reason, + 'reversal_reason', + TOM_REVERSAL_REASON_CODES, + ); + validateTomAmountObject(errors, body.reversed_amount, 'reversed_amount'); + validateTomOptionalAmountObject(errors, body.compensation_amount, 'compensation_amount'); + validateTomOptionalString(errors, body.reversal_identification, 'reversal_identification', 35); + validateTomOptionalString(errors, body.request_narrative, 'request_narrative', 500); + validateTomWebhookUrl(errors, body.webhook_url, 'webhook_url'); + return errors; +} diff --git a/reference-server/test/app.test.js b/reference-server/test/app.test.js new file mode 100644 index 0000000..20bca9c --- /dev/null +++ b/reference-server/test/app.test.js @@ -0,0 +1,4545 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createHmac } from 'node:crypto'; + +import { buildApp } from '../src/app.js'; +import { createMockEvmChainAdapter } from '../src/chain/mock-evm-adapter.js'; +import { createSepoliaUsdcAdapter } from '../src/chain/sepolia-usdc-adapter.js'; + +function isObject(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function deepMerge(base, overrides = {}) { + if (!isObject(base) || !isObject(overrides)) { + return overrides === undefined ? base : overrides; + } + + const result = { ...base }; + for (const [key, value] of Object.entries(overrides)) { + if (isObject(value) && isObject(base[key])) { + result[key] = deepMerge(base[key], value); + } else { + result[key] = value; + } + } + return result; +} + +function buildParsedTransferLog({ + tokenAddress, + from, + to, + amountRaw, + logIndex = 0, +}) { + return { + address: tokenAddress, + eventName: 'Transfer', + args: { + from, + to, + value: BigInt(amountRaw), + }, + logIndex, + }; +} + +function buildQuoteRequest(overrides = {}) { + return deepMerge( + { + token: { + token_symbol: 'USDC', + token_dti: 'T9B3X8H2K', + }, + chain_dli: 'X9J9XDMTD', + amount: '250000.00', + currency: 'USD', + custody_model: 'FULL_CUSTODY', + }, + overrides, + ); +} + +function buildInstructionPayload(overrides = {}) { + return deepMerge( + { + payment_identification: { + end_to_end_identification: 'INV-BASE', + }, + charge_bearer: 'DEBT', + debtor: { + name: 'Acme Trading GmbH', + lei: '529900T8BM49AURSDO55', + }, + debtor_agent: { + name: 'Bankhaus Example AG', + lei: '7245007VX57GR4IUVZ79', + }, + creditor: { + name: 'Bravo Supplies B.V.', + lei: '724500QHKL6MVSQQ1Z17', + }, + creditor_agent: { + name: 'Kraken Belgium BVBA', + lei: '635400DUFB71VFOHVB49', + }, + interbank_settlement_amount: { + amount: '250000.00', + currency: 'USD', + }, + blockchain_instruction: { + token: { + token_symbol: 'USDC', + token_dti: 'T9B3X8H2K', + }, + chain_dli: 'X9J9XDMTD', + custody_model: 'FULL_CUSTODY', + }, + }, + overrides, + ); +} + +function buildTravelRuleSubmission(overrides = {}) { + return deepMerge( + { + submission_timing: 'PRE_TX', + travel_rule_data: { + payment_identification: { + end_to_end_identification: 'E2E-TR-BASE', + }, + interbank_settlement_amount: { + amount: '50000.00', + currency: 'EUR', + }, + charge_bearer: 'SHAR', + debtor: { + name: 'Acme Trading GmbH', + postal_address: { + country: 'DE', + }, + }, + debtor_account: { + proxy: { + identification: '0xabc', + }, + }, + debtor_agent: { + name: 'Coinbase Europe Ltd', + lei: '7245007VX57GR4IUVZ79', + }, + creditor: { + name: 'Bravo Supplies B.V.', + postal_address: { + country: 'NL', + }, + }, + creditor_account: { + proxy: { + identification: '0xdef', + }, + }, + creditor_agent: { + name: 'Kraken Belgium BVBA', + lei: '635400DUFB71VFOHVB49', + }, + counterparty_wallet_type: 'HOSTED', + blockchain_settlement: { + primary_chain_id: 'DLID/X9J9XDMTD', + legs: [{ leg_type: 'ORIGINATION' }], + }, + }, + }, + overrides, + ); +} + +function buildTravelRuleCallback(overrides = {}) { + return deepMerge( + { + callback_status: 'ACCEPTED', + receiving_vasp: { + name: 'Bitvavo B.V.', + lei: '635400DUFB71VFOHVB49', + }, + callback_timestamp: new Date().toISOString(), + }, + overrides, + ); +} + +function buildInvestigationCasePayload(overrides = {}) { + return deepMerge( + { + case_type: 'BENEFICIARY_CREDIT_QUERY', + priority: 'HIGH', + requires_counterparty_action: true, + reason_code: 'BENEFICIARY_NOT_CREDITED', + narrative: + 'Beneficiary reports no credit despite final on-chain settlement.', + opened_by: { + name: 'Acme Bank Operations', + }, + counterparty: { + name: 'Receiving VASP Operations', + }, + }, + overrides, + ); +} + +function buildReturnCasePayload(overrides = {}) { + return deepMerge( + { + return_type: 'CUSTOMER_REFUND', + return_method: 'OFF_CHAIN_REFUND', + return_amount: { + amount: '250000.00', + currency: 'USD', + }, + reason_code: 'BENEFICIARY_REJECTED_FUNDS', + narrative: + 'Funds must be remediated after beneficiary-side exception handling.', + opened_by: { + name: 'Acme Bank Operations', + }, + counterparty: { + name: 'Receiving VASP Operations', + }, + }, + overrides, + ); +} + +function assertUuid(value) { + assert.match( + value, + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i, + ); +} + +function assertIsoDateTime(value) { + assert.equal(Number.isNaN(Date.parse(value)), false); +} + +function assertTravelRuleRecordShape(record) { + assertUuid(record.record_id); + assertIsoDateTime(record.submitted_at); + assert.ok(record.status); + assert.ok(record.travel_rule_data); +} + +function assertTravelRuleSearchResponseShape(response) { + assert.equal(typeof response.total_matched, 'number'); + assert.equal(typeof response.page_size, 'number'); + assertIsoDateTime(response.generated_at); + assert.ok(Array.isArray(response.records)); +} + +function assertTravelRuleStatsResponseShape(response) { + assertIsoDateTime(response.generated_at); + assert.ok(response.period); + assert.ok(response.totals); + assert.equal(typeof response.totals.record_count, 'number'); + assert.ok(Array.isArray(response.totals.volumes)); +} + +function assertReportEntrySearchResponseShape(response) { + assert.equal(typeof response.total_matched, 'number'); + assert.equal(typeof response.page_size, 'number'); + assertIsoDateTime(response.generated_at); + assert.ok(Array.isArray(response.entries)); +} + +function assertReportIntradayShape(response) { + assert.ok(response.group_header); + assert.ok(response.report); + assert.equal(typeof response.group_header.message_identification, 'string'); + assertIsoDateTime(response.group_header.creation_date_time); + assert.equal(typeof response.report.identification, 'string'); + assertIsoDateTime(response.report.creation_date_time); + assert.ok(response.report.account); + assert.ok(response.report.from_to_date); + assert.ok(Array.isArray(response.report.entries)); +} + +function assertReportStatementShape(response) { + assert.ok(response.group_header); + assert.ok(response.statement); + assert.equal(typeof response.group_header.message_identification, 'string'); + assertIsoDateTime(response.group_header.creation_date_time); + assert.equal(typeof response.statement.identification, 'string'); + assertIsoDateTime(response.statement.creation_date_time); + assert.ok(response.statement.account); + assert.ok(response.statement.from_to_date); + assert.ok(Array.isArray(response.statement.entries)); +} + +function assertBlockchainNotificationShape(response) { + assert.ok(response.group_header); + assert.equal(typeof response.notification_id, 'string'); + assert.ok(response.notification_type); + assert.ok(response.account); + assert.ok(response.entry); + assert.equal(typeof response.entry.entry_reference, 'string'); + assert.ok(response.entry.entry_status); +} + +function assertReportStatsResponseShape(response) { + assertIsoDateTime(response.generated_at); + assert.equal(typeof response.wallet_address, 'string'); + assert.equal(typeof response.chain_dli, 'string'); + assert.ok(response.period); + assert.ok(Array.isArray(response.totals)); +} + +function assertQuoteResponseShape(response) { + assertUuid(response.quote_id); + assertIsoDateTime(response.valid_until); + assertIsoDateTime(response.created_at); + assert.ok(response.fee_estimate); +} + +function assertInstructionResponseShape(response) { + assertUuid(response.instruction_id); + assertUuid(response.uetr); + assert.ok(response.status); + assert.ok(response.custody_model); + assert.ok(response.fee_estimate); + assertIsoDateTime(response.created_at); +} + +function assertInstructionStatusResponseShape(response) { + assertUuid(response.instruction_id); + assertUuid(response.uetr); + assert.ok(response.status); + assert.ok(response.payment_identification); + assert.ok(response.interbank_settlement_amount); + assert.ok(response.blockchain_instruction); + assertIsoDateTime(response.created_at); + assertIsoDateTime(response.updated_at); +} + +function assertInstructionSearchResponseShape(response) { + assert.equal(typeof response.total_matched, 'number'); + assert.equal(typeof response.page_size, 'number'); + assertIsoDateTime(response.generated_at); + assert.ok(Array.isArray(response.instructions)); +} + +function assertTravelRuleCallbackReceiptShape(response) { + assertUuid(response.record_id); + assertIsoDateTime(response.callback_recorded_at); + assert.ok(response.current_status); +} + +function assertCancellationResponseShape(response) { + assertUuid(response.instruction_id); + assert.equal(response.status, 'CANCELLED'); + assertIsoDateTime(response.cancelled_at); +} + +function assertAdapterMetadataShape(metadata, expectedAdapterId = null) { + assert.ok(metadata); + assert.equal(typeof metadata.adapter_id, 'string'); + assert.equal(typeof metadata.adapter_mode, 'string'); + assert.equal(typeof metadata.chain_family, 'string'); + assert.equal(typeof metadata.chain_dli, 'string'); + assert.ok(metadata.lifecycle_policy); + assert.ok(metadata.fee_model); + if (expectedAdapterId) { + assert.equal(metadata.adapter_id, expectedAdapterId); + } +} + +function assertInvestigationCaseShape(response) { + assertUuid(response.investigation_case_id); + assert.equal(response.exception_type, 'INVESTIGATION'); + assertUuid(response.related_instruction_id); + assertUuid(response.related_uetr); + assertIsoDateTime(response.opened_at); + assertIsoDateTime(response.updated_at); + assert.ok(Array.isArray(response.status_history)); + assert.ok(response.traceability); +} + +function assertReturnCaseShape(response) { + assertUuid(response.return_case_id); + assert.equal(response.exception_type, 'RETURN'); + assertUuid(response.original_instruction_id); + assertUuid(response.original_uetr); + assertIsoDateTime(response.opened_at); + assertIsoDateTime(response.updated_at); + assert.ok(Array.isArray(response.status_history)); + assert.ok(response.traceability); +} + +async function waitFor(assertion, { timeoutMs = 1500, intervalMs = 20 } = {}) { + const deadline = Date.now() + timeoutMs; + let lastError = null; + + while (Date.now() < deadline) { + try { + return await assertion(); + } catch (error) { + lastError = error; + await new Promise((resolve) => { + setTimeout(resolve, intervalMs); + }); + } + } + + throw lastError ?? new Error('Timed out waiting for condition.'); +} + +test('travel rule submit -> callback -> retrieve', async () => { + const app = await buildApp(); + + const createResponse = await app.inject({ + method: 'POST', + url: '/travel-rule', + payload: buildTravelRuleSubmission({ + travel_rule_data: { + payment_identification: { + end_to_end_identification: 'E2E-TR-001', + }, + }, + }), + }); + + assert.equal(createResponse.statusCode, 201); + const createdRecord = createResponse.json(); + assertTravelRuleRecordShape(createdRecord); + assert.equal(createdRecord.status, 'SUBMITTED'); + assert.equal(createdRecord.submission_timing, 'PRE_TX'); + + const callbackResponse = await app.inject({ + method: 'POST', + url: `/travel-rule/${createdRecord.record_id}/callback`, + payload: buildTravelRuleCallback({ + description: 'Data quality sufficient.', + }), + }); + + assert.equal(callbackResponse.statusCode, 200); + assertTravelRuleCallbackReceiptShape(callbackResponse.json()); + assert.equal(callbackResponse.json().current_status, 'ACCEPTED'); + assert.equal(callbackResponse.json().previous_status, 'SUBMITTED'); + + const getResponse = await app.inject({ + method: 'GET', + url: `/travel-rule/${createdRecord.record_id}`, + }); + + assert.equal(getResponse.statusCode, 200); + assert.equal(getResponse.json().callbacks.length, 1); + assertTravelRuleRecordShape(getResponse.json()); + + await app.close(); +}); + +test('quote -> instruction -> get status', async () => { + const app = await buildApp(); + + const quoteResponse = await app.inject({ + method: 'POST', + url: '/instruction/quote', + payload: buildQuoteRequest(), + }); + + assert.equal(quoteResponse.statusCode, 200); + const quote = quoteResponse.json(); + assertQuoteResponseShape(quote); + assert.ok(quote.quote_id); + assertAdapterMetadataShape(quote.adapter_metadata, 'mock-evm'); + + const instructionResponse = await app.inject({ + method: 'POST', + url: '/instruction', + payload: buildInstructionPayload({ + payment_identification: { + end_to_end_identification: 'INV-042', + quote_id: quote.quote_id, + }, + interbank_settlement_amount: { + amount: '250000.00', + currency: 'USD', + }, + }), + }); + + assert.equal(instructionResponse.statusCode, 201); + const instruction = instructionResponse.json(); + assertInstructionResponseShape(instruction); + assert.equal(instruction.status, 'PENDING'); + assert.equal(instruction.debit_timing, 'ON_BROADCAST'); + assert.ok(instruction.fee_estimate); + assertAdapterMetadataShape(instruction.adapter_metadata, 'mock-evm'); + + const getResponse = await app.inject({ + method: 'GET', + url: `/instruction/${instruction.instruction_id}`, + }); + + assert.equal(getResponse.statusCode, 200); + assertInstructionStatusResponseShape(getResponse.json()); + assert.ok(getResponse.json().instruction_id); + assert.ok(['PENDING', 'BROADCAST', 'CONFIRMING', 'FINAL'].includes(getResponse.json().status)); + assertAdapterMetadataShape(getResponse.json().adapter_metadata, 'mock-evm'); + + const searchResponse = await app.inject({ + method: 'GET', + url: '/instruction/search?status=PENDING&page_size=10', + }); + + assert.equal(searchResponse.statusCode, 200); + assertInstructionSearchResponseShape(searchResponse.json()); + assert.ok(Array.isArray(searchResponse.json().instructions)); + assert.equal(searchResponse.json().total_matched, 1); + assert.equal(searchResponse.json().instructions[0].debtor_name, 'Acme Trading GmbH'); + + await app.close(); +}); + +test('quote request validates required fields', async () => { + const app = await buildApp(); + + const response = await app.inject({ + method: 'POST', + url: '/instruction/quote', + payload: { + chain_dli: 'X9J9XDMTD', + }, + }); + + assert.equal(response.statusCode, 400); + assert.equal(response.json().code, 'INVALID_REQUEST'); + assert.equal(response.json().message, 'Request validation failed.'); + assert.ok(Array.isArray(response.json().details)); + assert.equal( + response.json().details.some((detail) => detail.field === 'token'), + true, + ); + + await app.close(); +}); + +test('instruction submission enforces spec-required parties and charge bearer', async () => { + const app = await buildApp(); + + const response = await app.inject({ + method: 'POST', + url: '/instruction', + payload: { + payment_identification: { + end_to_end_identification: 'INV-MISSING-FIELDS', + }, + interbank_settlement_amount: { + amount: '25.00', + currency: 'USD', + }, + blockchain_instruction: { + token: { + token_symbol: 'USDC', + }, + chain_dli: 'bad-chain', + custody_model: 'BAD_MODEL', + }, + }, + }); + + assert.equal(response.statusCode, 400); + assert.equal(response.json().code, 'INVALID_REQUEST'); + assert.equal( + response.json().details.some((detail) => detail.field === 'charge_bearer'), + true, + ); + assert.equal( + response.json().details.some((detail) => detail.field === 'debtor'), + true, + ); + assert.equal( + response.json().details.some( + (detail) => detail.field === 'blockchain_instruction.chain_dli', + ), + true, + ); + assert.equal( + response.json().details.some( + (detail) => detail.field === 'blockchain_instruction.custody_model', + ), + true, + ); + + await app.close(); +}); + +test('travel rule callback enforces receiving vasp identity and timestamp', async () => { + const app = await buildApp(); + + const createResponse = await app.inject({ + method: 'POST', + url: '/travel-rule', + payload: buildTravelRuleSubmission({ + travel_rule_data: { + payment_identification: { + end_to_end_identification: 'E2E-CB-VALIDATION-001', + }, + }, + }), + }); + + assert.equal(createResponse.statusCode, 201); + + const callbackResponse = await app.inject({ + method: 'POST', + url: `/travel-rule/${createResponse.json().record_id}/callback`, + payload: { + callback_status: 'REJECTED', + rejection_reasons: [{ code: 'INVALID_FORMAT' }], + }, + }); + + assert.equal(callbackResponse.statusCode, 400); + assert.equal( + callbackResponse.json().details.some( + (detail) => detail.field === 'receiving_vasp', + ), + true, + ); + assert.equal( + callbackResponse.json().details.some( + (detail) => detail.field === 'callback_timestamp', + ), + true, + ); + + await app.close(); +}); + +test('duplicate end_to_end_identification returns 409 with original instruction id', async () => { + const app = await buildApp(); + + const payload = { + ...buildInstructionPayload(), + payment_identification: { + end_to_end_identification: 'INV-042', + }, + }; + + const firstResponse = await app.inject({ + method: 'POST', + url: '/instruction', + payload, + }); + + const duplicateResponse = await app.inject({ + method: 'POST', + url: '/instruction', + payload, + }); + + assert.equal(firstResponse.statusCode, 201); + assert.equal(duplicateResponse.statusCode, 409); + assert.equal( + duplicateResponse.json().instruction_id, + firstResponse.json().instruction_id, + ); + + await app.close(); +}); + +test('pending instruction can be cancelled before broadcast', async () => { + const app = await buildApp(); + + const createResponse = await app.inject({ + method: 'POST', + url: '/instruction', + payload: buildInstructionPayload({ + payment_identification: { + end_to_end_identification: 'INV-043', + }, + interbank_settlement_amount: { + amount: '1000.00', + currency: 'USD', + }, + }), + }); + + const cancelResponse = await app.inject({ + method: 'DELETE', + url: `/instruction/${createResponse.json().instruction_id}`, + }); + + assert.equal(cancelResponse.statusCode, 200); + assertCancellationResponseShape(cancelResponse.json()); + + await app.close(); +}); + +test('travel rule search validates spec query parameters', async () => { + const app = await buildApp(); + + const response = await app.inject({ + method: 'GET', + url: '/travel-rule/search?direction=SIDEWAYS&submitted_from=bad-date&status=UNKNOWN&page_size=999&sort=latest', + }); + + assert.equal(response.statusCode, 400); + assert.equal(response.json().code, 'INVALID_REQUEST'); + assert.equal( + response.json().details.some((detail) => detail.field === 'direction'), + true, + ); + assert.equal( + response.json().details.some((detail) => detail.field === 'submitted_from'), + true, + ); + assert.equal( + response.json().details.some((detail) => detail.field === 'status'), + true, + ); + assert.equal( + response.json().details.some((detail) => detail.field === 'page_size'), + true, + ); + assert.equal( + response.json().details.some((detail) => detail.field === 'sort'), + true, + ); + + await app.close(); +}); + +test('travel rule search applies currency wallet and sort filters from the spec', async () => { + const app = await buildApp(); + + const firstResponse = await app.inject({ + method: 'POST', + url: '/travel-rule', + payload: buildTravelRuleSubmission({ + travel_rule_data: { + payment_identification: { + end_to_end_identification: 'E2E-SEARCH-001', + }, + interbank_settlement_amount: { + amount: '100.00', + currency: 'USD', + }, + debtor_account: { + proxy: { + identification: '0xdebtorsearch', + }, + }, + creditor_account: { + proxy: { + identification: '0xcreditorone', + }, + }, + }, + }), + }); + + const secondResponse = await app.inject({ + method: 'POST', + url: '/travel-rule', + payload: buildTravelRuleSubmission({ + travel_rule_data: { + payment_identification: { + end_to_end_identification: 'E2E-SEARCH-002', + }, + interbank_settlement_amount: { + amount: '250.00', + currency: 'USD', + }, + debtor_account: { + proxy: { + identification: '0xdebtorsearch', + }, + }, + creditor_account: { + proxy: { + identification: '0xcreditortwo', + }, + }, + }, + }), + }); + + const thirdResponse = await app.inject({ + method: 'POST', + url: '/travel-rule', + payload: buildTravelRuleSubmission({ + travel_rule_data: { + payment_identification: { + end_to_end_identification: 'E2E-SEARCH-003', + }, + interbank_settlement_amount: { + amount: '999.00', + currency: 'EUR', + }, + debtor_account: { + proxy: { + identification: '0xotherdebtor', + }, + }, + }, + }), + }); + + assert.equal(firstResponse.statusCode, 201); + assert.equal(secondResponse.statusCode, 201); + assert.equal(thirdResponse.statusCode, 201); + + const response = await app.inject({ + method: 'GET', + url: '/travel-rule/search?currency=USD&debtor_wallet=0xdebtorsearch&sort=amount_desc', + }); + + assert.equal(response.statusCode, 200); + assertTravelRuleSearchResponseShape(response.json()); + assert.equal(response.json().total_matched, 2); + assert.equal(response.json().records[0].settlement_amount, '250.00'); + assert.equal(response.json().records[1].settlement_amount, '100.00'); + + await app.close(); +}); + +test('ramp instructions fail early when estimated slippage exceeds the configured limit', async () => { + const app = await buildApp(); + + const createResponse = await app.inject({ + method: 'POST', + url: '/instruction', + payload: buildInstructionPayload({ + payment_identification: { + end_to_end_identification: 'INV-RAMP-SLIP-001', + }, + interbank_settlement_amount: { + amount: '25000.00', + currency: 'USD', + }, + blockchain_instruction: { + token: { + token_symbol: 'USDC', + token_dti: 'T9B3X8H2K', + }, + chain_dli: 'X9J9XDMTD', + custody_model: 'FULL_CUSTODY', + maximum_slippage_rate: '0.0005', + ramp_instruction: { + ramp_type: 'ONRAMP', + }, + }, + }), + }); + + assert.equal(createResponse.statusCode, 201); + + const statusResponse = await app.inject({ + method: 'GET', + url: `/execution-status/${createResponse.json().instruction_id}`, + }); + + assert.equal(statusResponse.statusCode, 200); + assert.equal(statusResponse.json().status, 'SLIPPAGE_EXCEEDED'); + assert.match(statusResponse.json().failure_reason, /Estimated slippage/); + assert.equal(statusResponse.json().transaction_hash, null); + assert.equal(statusResponse.json().confirmation_depth, 0); + + await app.close(); +}); + +test('instruction search validates spec query parameters', async () => { + const app = await buildApp(); + + const response = await app.inject({ + method: 'GET', + url: '/instruction/search?from=bad-date&status=UNKNOWN&chain_dli=bad&page_size=9999', + }); + + assert.equal(response.statusCode, 400); + assert.equal(response.json().code, 'INVALID_REQUEST'); + assert.equal( + response.json().details.some((detail) => detail.field === 'from'), + true, + ); + assert.equal( + response.json().details.some((detail) => detail.field === 'status'), + true, + ); + assert.equal( + response.json().details.some((detail) => detail.field === 'chain_dli'), + true, + ); + assert.equal( + response.json().details.some((detail) => detail.field === 'page_size'), + true, + ); + + await app.close(); +}); + +test('delegated signing remains explicitly out of scope', async () => { + const app = await buildApp(); + + const createResponse = await app.inject({ + method: 'POST', + url: '/instruction', + payload: buildInstructionPayload({ + payment_identification: { + end_to_end_identification: 'INV-DELEGATED-001', + }, + blockchain_instruction: { + token: { + token_symbol: 'USDC', + token_dti: 'T9B3X8H2K', + }, + chain_dli: 'X9J9XDMTD', + custody_model: 'DELEGATED_SIGNING', + }, + }), + }); + + assert.equal(createResponse.statusCode, 501); + assert.equal(createResponse.json().error, 'not_implemented'); + + const signedTransactionResponse = await app.inject({ + method: 'POST', + url: '/instruction/550e8400-e29b-41d4-a716-446655440000/signed-transaction', + payload: { + signed_transaction: '0xdeadbeef', + }, + }); + + assert.equal(signedTransactionResponse.statusCode, 501); + assert.equal(signedTransactionResponse.json().error, 'not_implemented'); + + await app.close(); +}); + +test('travel rule search and stats return spec-like envelope', async () => { + const app = await buildApp(); + + const createResponse = await app.inject({ + method: 'POST', + url: '/travel-rule', + payload: buildTravelRuleSubmission({ + submission_timing: 'POST_TX', + travel_rule_data: { + payment_identification: { + end_to_end_identification: 'E2E-001', + }, + interbank_settlement_amount: { + amount: '50000.00', + currency: 'EUR', + }, + blockchain_settlement: { + primary_chain_id: 'DLID/X9J9XDMTD', + legs: [{ leg_type: 'ORIGINATION' }], + }, + }, + }), + }); + + assert.equal(createResponse.statusCode, 201); + + const searchResponse = await app.inject({ + method: 'GET', + url: '/travel-rule/search?status=SUBMITTED&submitted_from=2025-01-01T00:00:00Z&submitted_to=2030-01-01T00:00:00Z&page_size=1', + }); + + assert.equal(searchResponse.statusCode, 200); + assertTravelRuleSearchResponseShape(searchResponse.json()); + assert.equal(searchResponse.json().total_matched, 1); + assert.equal(searchResponse.json().page_size, 1); + assert.equal(searchResponse.json().records[0].latest_callback_status, 'PENDING'); + assert.equal(searchResponse.json().records[0].primary_chain_id, 'DLID/X9J9XDMTD'); + + const statsResponse = await app.inject({ + method: 'GET', + url: '/travel-rule/stats?submitted_from=2025-01-01T00:00:00Z&submitted_to=2030-01-01T00:00:00Z&group_by=status', + }); + + assert.equal(statsResponse.statusCode, 200); + assertTravelRuleStatsResponseShape(statsResponse.json()); + assert.equal(statsResponse.json().totals.record_count, 1); + assert.equal(statsResponse.json().totals.volumes[0].currency, 'EUR'); + assert.equal(statsResponse.json().direction, 'BOTH'); + assert.equal(statsResponse.json().breakdown[0].dimension_value, 'SUBMITTED'); + + await app.close(); +}); + +test('accepted travel rule record rejects superseding callback', async () => { + const app = await buildApp(); + + const createResponse = await app.inject({ + method: 'POST', + url: '/travel-rule', + payload: buildTravelRuleSubmission({ + travel_rule_data: { + payment_identification: { + end_to_end_identification: 'E2E-ACCEPTED-001', + }, + }, + }), + }); + + assert.equal(createResponse.statusCode, 201); + + const acceptedResponse = await app.inject({ + method: 'POST', + url: `/travel-rule/${createResponse.json().record_id}/callback`, + payload: buildTravelRuleCallback(), + }); + + assert.equal(acceptedResponse.statusCode, 200); + assert.equal(acceptedResponse.json().current_status, 'ACCEPTED'); + + const rejectedResponse = await app.inject({ + method: 'POST', + url: `/travel-rule/${createResponse.json().record_id}/callback`, + payload: buildTravelRuleCallback({ + callback_status: 'REJECTED', + rejection_reasons: [{ field: 'debtor.name', code: 'INVALID_FORMAT' }], + }), + }); + + assert.equal(rejectedResponse.statusCode, 409); + + await app.close(); +}); + +test('execution status can be retrieved by instruction id and uetr', async () => { + const app = await buildApp(); + + const createResponse = await app.inject({ + method: 'POST', + url: '/instruction', + payload: buildInstructionPayload({ + payment_identification: { + end_to_end_identification: 'INV-044', + }, + interbank_settlement_amount: { + amount: '2750.00', + currency: 'USD', + }, + }), + }); + + assert.equal(createResponse.statusCode, 201); + const instruction = createResponse.json(); + + const byInstructionIdResponse = await app.inject({ + method: 'GET', + url: `/execution-status/${instruction.instruction_id}`, + }); + + assert.equal(byInstructionIdResponse.statusCode, 200); + assert.equal(byInstructionIdResponse.json().status, 'PENDING'); + assert.equal(byInstructionIdResponse.json().status_group, 'PRE_EXECUTION'); + assert.equal(byInstructionIdResponse.json().transaction_hash, null); + assert.equal(byInstructionIdResponse.json().status_history.length, 1); + assert.equal(byInstructionIdResponse.json().status_history[0].status, 'PENDING'); + assertAdapterMetadataShape(byInstructionIdResponse.json().adapter_metadata, 'mock-evm'); + + const byUetrResponse = await app.inject({ + method: 'GET', + url: `/execution-status/uetr/${instruction.uetr}`, + }); + + assert.equal(byUetrResponse.statusCode, 200); + assert.equal( + byUetrResponse.json().instruction_id, + instruction.instruction_id, + ); + + await app.close(); +}); + +test('finality receipt reflects settled on-chain state and supports uetr lookup', async () => { + const app = await buildApp(); + + const createResponse = await app.inject({ + method: 'POST', + url: '/instruction', + payload: buildInstructionPayload({ + payment_identification: { + end_to_end_identification: 'INV-045', + }, + interbank_settlement_amount: { + amount: '5000.00', + currency: 'USD', + }, + }), + }); + + assert.equal(createResponse.statusCode, 201); + const instruction = createResponse.json(); + const currentRecord = app.store.getInstruction(instruction.instruction_id); + const agedTimestamp = new Date(Date.now() - 12000).toISOString(); + app.store.saveInstruction({ + ...currentRecord, + created_at: agedTimestamp, + updated_at: agedTimestamp, + }); + + const byInstructionIdResponse = await app.inject({ + method: 'GET', + url: `/finality-receipt/${instruction.instruction_id}`, + }); + + assert.equal(byInstructionIdResponse.statusCode, 200); + assert.equal(byInstructionIdResponse.json().instruction_status, 'FINAL'); + assert.equal(byInstructionIdResponse.json().finality_status, 'FINAL'); + assert.equal(byInstructionIdResponse.json().confirmation_depth, 12); + assert.ok(byInstructionIdResponse.json().transaction_hash); + assert.ok(byInstructionIdResponse.json().final_at); + assertAdapterMetadataShape(byInstructionIdResponse.json().adapter_metadata, 'mock-evm'); + + const byUetrResponse = await app.inject({ + method: 'GET', + url: `/finality-receipt/uetr/${instruction.uetr}`, + }); + + assert.equal(byUetrResponse.statusCode, 200); + assert.equal(byUetrResponse.json().instruction_id, instruction.instruction_id); + + await app.close(); +}); + +test('finality receipt remains stable after execution status has already advanced the instruction', async () => { + const app = await buildApp(); + + const createResponse = await app.inject({ + method: 'POST', + url: '/instruction', + payload: buildInstructionPayload({ + payment_identification: { + end_to_end_identification: 'INV-FINALITY-STABLE-001', + }, + interbank_settlement_amount: { + amount: '125000.00', + currency: 'USD', + }, + }), + }); + + assert.equal(createResponse.statusCode, 201); + const instruction = createResponse.json(); + const currentRecord = app.store.getInstruction(instruction.instruction_id); + const agedTimestamp = new Date(Date.now() - 12000).toISOString(); + app.store.saveInstruction({ + ...currentRecord, + created_at: agedTimestamp, + updated_at: agedTimestamp, + }); + + const statusResponse = await app.inject({ + method: 'GET', + url: `/execution-status/${instruction.instruction_id}`, + }); + + assert.equal(statusResponse.statusCode, 200); + assert.equal(statusResponse.json().status, 'FINAL'); + assert.ok(statusResponse.json().transaction_hash); + + const finalityResponse = await app.inject({ + method: 'GET', + url: `/finality-receipt/${instruction.instruction_id}`, + }); + + assert.equal(finalityResponse.statusCode, 200); + assert.equal(finalityResponse.json().instruction_status, 'FINAL'); + assert.equal(finalityResponse.json().finality_status, 'FINAL'); + assert.equal( + finalityResponse.json().transaction_hash, + statusResponse.json().transaction_hash, + ); + assert.ok(finalityResponse.json().block_number); + assert.ok(finalityResponse.json().block_timestamp); + + await app.close(); +}); + +test('execution status history captures cancellation as a terminal event', async () => { + const app = await buildApp(); + + const createResponse = await app.inject({ + method: 'POST', + url: '/instruction', + payload: buildInstructionPayload({ + payment_identification: { + end_to_end_identification: 'INV-046', + }, + interbank_settlement_amount: { + amount: '310.00', + currency: 'USD', + }, + }), + }); + + assert.equal(createResponse.statusCode, 201); + + const cancelResponse = await app.inject({ + method: 'DELETE', + url: `/instruction/${createResponse.json().instruction_id}`, + }); + + assert.equal(cancelResponse.statusCode, 200); + + const statusResponse = await app.inject({ + method: 'GET', + url: `/execution-status/${createResponse.json().instruction_id}`, + }); + + assert.equal(statusResponse.statusCode, 200); + assert.equal(statusResponse.json().status, 'CANCELLED'); + assert.equal(statusResponse.json().status_group, 'CANCELLED'); + assert.equal(statusResponse.json().status_history.length, 2); + assert.equal( + statusResponse.json().status_history.at(-1).reason_code, + 'CANCELLED_BY_INSTRUCTING_PARTY', + ); + + await app.close(); +}); + +test('event outbox mirrors initial execution status and finality payloads', async () => { + const app = await buildApp(); + + const createResponse = await app.inject({ + method: 'POST', + url: '/instruction', + payload: buildInstructionPayload({ + payment_identification: { + end_to_end_identification: 'INV-047', + }, + interbank_settlement_amount: { + amount: '725.00', + currency: 'USD', + }, + }), + }); + + assert.equal(createResponse.statusCode, 201); + const instruction = createResponse.json(); + + const outboxResponse = await app.inject({ + method: 'GET', + url: `/event-outbox?instruction_id=${instruction.instruction_id}`, + }); + + assert.equal(outboxResponse.statusCode, 200); + assert.equal(outboxResponse.json().total_matched, 2); + assert.equal( + outboxResponse.json().events.some((event) => event.event_type === 'execution_status.updated'), + true, + ); + assert.equal( + outboxResponse.json().events.some((event) => event.event_type === 'finality_receipt.updated'), + true, + ); + + const executionStatusEvent = outboxResponse.json().events.find( + (event) => event.event_type === 'execution_status.updated', + ); + const finalityEvent = outboxResponse.json().events.find( + (event) => event.event_type === 'finality_receipt.updated', + ); + + assert.equal(executionStatusEvent.payload.status, 'PENDING'); + assert.equal(finalityEvent.payload.instruction_status, 'PENDING'); + + const eventLookupResponse = await app.inject({ + method: 'GET', + url: `/event-outbox/${executionStatusEvent.event_id}`, + }); + + assert.equal(eventLookupResponse.statusCode, 200); + assert.equal(eventLookupResponse.json().event_id, executionStatusEvent.event_id); + + await app.close(); +}); + +test('event outbox records final lifecycle transitions with mirrored payloads', async () => { + const app = await buildApp(); + + const createResponse = await app.inject({ + method: 'POST', + url: '/instruction', + payload: buildInstructionPayload({ + payment_identification: { + end_to_end_identification: 'INV-048', + }, + interbank_settlement_amount: { + amount: '9825.00', + currency: 'USD', + }, + }), + }); + + assert.equal(createResponse.statusCode, 201); + const instruction = createResponse.json(); + const currentRecord = app.store.getInstruction(instruction.instruction_id); + const agedTimestamp = new Date(Date.now() - 12000).toISOString(); + app.store.saveInstruction({ + ...currentRecord, + created_at: agedTimestamp, + updated_at: agedTimestamp, + }); + + const statusResponse = await app.inject({ + method: 'GET', + url: `/execution-status/${instruction.instruction_id}`, + }); + + assert.equal(statusResponse.statusCode, 200); + assert.equal(statusResponse.json().status, 'FINAL'); + + const outboxResponse = await app.inject({ + method: 'GET', + url: `/event-outbox?instruction_id=${instruction.instruction_id}&event_type=execution_status.updated,finality_receipt.updated`, + }); + + assert.equal(outboxResponse.statusCode, 200); + assert.equal(outboxResponse.json().total_matched, 4); + + const finalStatusEvent = outboxResponse.json().events.find( + (event) => + event.event_type === 'execution_status.updated' && + event.payload.status === 'FINAL', + ); + const finalityEvent = outboxResponse.json().events.find( + (event) => + event.event_type === 'finality_receipt.updated' && + event.payload.finality_status === 'FINAL', + ); + + assert.ok(finalStatusEvent); + assert.ok(finalityEvent); + assert.equal(finalStatusEvent.payload.instruction_id, instruction.instruction_id); + assert.equal(finalityEvent.payload.instruction_id, instruction.instruction_id); + + await app.close(); +}); + +test('webhook subscriptions receive signed deliveries for outbox events', async () => { + const signingSecret = 'whsec_test_123456'; + const deliveriesReceived = []; + const app = await buildApp({ + webhookSender: async ({ url, headers, body }) => { + deliveriesReceived.push({ url, headers, body }); + return { + status: 202, + bodyText: 'accepted', + }; + }, + }); + + const subscriptionResponse = await app.inject({ + method: 'POST', + url: '/webhook-endpoints', + payload: { + url: 'https://receiver.example/pacs', + signing_secret: signingSecret, + subscribed_event_types: [ + 'execution_status.updated', + 'finality_receipt.updated', + ], + description: 'Treasury lifecycle receiver', + }, + }); + + assert.equal(subscriptionResponse.statusCode, 201); + const subscription = subscriptionResponse.json(); + assert.equal(subscription.signing_secret, signingSecret); + + const createResponse = await app.inject({ + method: 'POST', + url: '/instruction', + payload: buildInstructionPayload({ + payment_identification: { + end_to_end_identification: 'INV-049', + }, + interbank_settlement_amount: { + amount: '1800.00', + currency: 'USD', + }, + }), + }); + + assert.equal(createResponse.statusCode, 201); + const instruction = createResponse.json(); + + const pendingDeliveriesResponse = await app.inject({ + method: 'GET', + url: `/webhook-endpoints/${subscription.subscription_id}/deliveries`, + }); + + assert.equal(pendingDeliveriesResponse.statusCode, 200); + assert.equal(pendingDeliveriesResponse.json().total_matched, 2); + + const dispatchResponse = await app.inject({ + method: 'POST', + url: '/webhook-deliveries/dispatch', + payload: { + subscription_id: subscription.subscription_id, + limit: 10, + }, + }); + + assert.equal(dispatchResponse.statusCode, 200); + assert.equal(dispatchResponse.json().dispatched_count, 2); + assert.equal(deliveriesReceived.length, 2); + + const firstDelivery = deliveriesReceived[0]; + const timestamp = firstDelivery.headers['x-pacscrypto-signature-timestamp']; + const expectedDigest = createHmac('sha256', signingSecret) + .update(`${timestamp}.${firstDelivery.body}`) + .digest('hex'); + assert.equal( + firstDelivery.headers['x-pacscrypto-signature'], + `t=${timestamp},v1=${expectedDigest}`, + ); + + const parsedEnvelope = JSON.parse(firstDelivery.body); + assert.equal(parsedEnvelope.instruction_id, instruction.instruction_id); + assert.ok( + ['execution_status.updated', 'finality_receipt.updated'].includes( + parsedEnvelope.event_type, + ), + ); + + const deliveredResponse = await app.inject({ + method: 'GET', + url: `/webhook-endpoints/${subscription.subscription_id}/deliveries?delivery_state=DELIVERED`, + }); + + assert.equal(deliveredResponse.statusCode, 200); + assert.equal(deliveredResponse.json().total_matched, 2); + assert.equal( + deliveredResponse.json().deliveries.every( + (delivery) => delivery.response_status === 202, + ), + true, + ); + + const subscriptionLookupResponse = await app.inject({ + method: 'GET', + url: `/webhook-endpoints/${subscription.subscription_id}`, + }); + + assert.equal(subscriptionLookupResponse.statusCode, 200); + assert.ok(subscriptionLookupResponse.json().last_delivery_at); + + await app.close(); +}); + +test('webhook deliveries retry on non-2xx endpoint responses', async () => { + const app = await buildApp({ + webhookSender: async () => ({ + status: 500, + bodyText: 'upstream unavailable', + }), + }); + + const subscriptionResponse = await app.inject({ + method: 'POST', + url: '/webhook-endpoints', + payload: { + url: 'https://receiver.example/retry', + signing_secret: 'whsec_retry_123456', + subscribed_event_types: ['execution_status.updated'], + }, + }); + + assert.equal(subscriptionResponse.statusCode, 201); + const subscription = subscriptionResponse.json(); + + const createResponse = await app.inject({ + method: 'POST', + url: '/instruction', + payload: buildInstructionPayload({ + payment_identification: { + end_to_end_identification: 'INV-050', + }, + interbank_settlement_amount: { + amount: '900.00', + currency: 'USD', + }, + }), + }); + + assert.equal(createResponse.statusCode, 201); + + const dispatchResponse = await app.inject({ + method: 'POST', + url: '/webhook-deliveries/dispatch', + payload: { + subscription_id: subscription.subscription_id, + }, + }); + + assert.equal(dispatchResponse.statusCode, 200); + assert.equal(dispatchResponse.json().dispatched_count, 1); + assert.equal(dispatchResponse.json().deliveries[0].delivery_state, 'RETRYING'); + + const retryingDeliveriesResponse = await app.inject({ + method: 'GET', + url: `/webhook-endpoints/${subscription.subscription_id}/deliveries?delivery_state=RETRYING`, + }); + + assert.equal(retryingDeliveriesResponse.statusCode, 200); + assert.equal(retryingDeliveriesResponse.json().total_matched, 1); + assert.equal(retryingDeliveriesResponse.json().deliveries[0].attempt_count, 1); + assert.equal(retryingDeliveriesResponse.json().deliveries[0].response_status, 500); + assert.match( + retryingDeliveriesResponse.json().deliveries[0].last_error, + /HTTP 500/, + ); + assert.ok( + Date.parse(retryingDeliveriesResponse.json().deliveries[0].next_attempt_at) > + Date.parse(retryingDeliveriesResponse.json().deliveries[0].last_attempt_at), + ); + + await app.close(); +}); + +test('background webhook dispatch retries and eventually delivers due events', async () => { + let attempts = 0; + const app = await buildApp({ + webhookDispatch: { + enabled: true, + intervalMs: 10, + batchSize: 10, + }, + webhookRetryScheduleMs: [15, 30], + webhookSender: async () => { + attempts += 1; + if (attempts === 1) { + return { + status: 503, + bodyText: 'busy', + }; + } + + return { + status: 202, + bodyText: 'accepted', + }; + }, + }); + + const subscriptionResponse = await app.inject({ + method: 'POST', + url: '/webhook-endpoints', + payload: { + url: 'https://receiver.example/background', + signing_secret: 'whsec_background_123456', + subscribed_event_types: ['execution_status.updated'], + max_attempts: 3, + }, + }); + + assert.equal(subscriptionResponse.statusCode, 201); + const subscription = subscriptionResponse.json(); + + const createResponse = await app.inject({ + method: 'POST', + url: '/instruction', + payload: buildInstructionPayload({ + payment_identification: { + end_to_end_identification: 'INV-WEBHOOK-BG-001', + }, + interbank_settlement_amount: { + amount: '1500.00', + currency: 'USD', + }, + }), + }); + + assert.equal(createResponse.statusCode, 201); + + const delivered = await waitFor(async () => { + const response = await app.inject({ + method: 'GET', + url: `/webhook-endpoints/${subscription.subscription_id}/deliveries?event_type=execution_status.updated`, + }); + + assert.equal(response.statusCode, 200); + assert.equal(response.json().total_matched, 1); + assert.equal(response.json().deliveries[0].delivery_state, 'DELIVERED'); + return response.json().deliveries[0]; + }); + + assert.ok(delivered.attempt_count >= 2); + assert.equal(delivered.response_status, 202); + assert.equal(attempts >= 2, true); + + await app.close(); +}); + +test('webhook deliveries move to dead-letter after max attempts and expose operator stats', async () => { + let attempts = 0; + const app = await buildApp({ + webhookDispatch: { + enabled: true, + intervalMs: 10, + batchSize: 10, + }, + webhookRetryScheduleMs: [10], + webhookSender: async () => { + attempts += 1; + return { + status: 502, + bodyText: 'upstream failed', + }; + }, + }); + + const subscriptionResponse = await app.inject({ + method: 'POST', + url: '/webhook-endpoints', + payload: { + url: 'https://receiver.example/dead-letter', + signing_secret: 'whsec_deadletter_123456', + subscribed_event_types: ['execution_status.updated'], + max_attempts: 2, + }, + }); + + assert.equal(subscriptionResponse.statusCode, 201); + const subscription = subscriptionResponse.json(); + + const createResponse = await app.inject({ + method: 'POST', + url: '/instruction', + payload: buildInstructionPayload({ + payment_identification: { + end_to_end_identification: 'INV-WEBHOOK-DEADLETTER-001', + }, + interbank_settlement_amount: { + amount: '950.00', + currency: 'USD', + }, + }), + }); + + assert.equal(createResponse.statusCode, 201); + + const deadLetterDelivery = await waitFor(async () => { + const response = await app.inject({ + method: 'GET', + url: `/webhook-deliveries/dead-letter?subscription_id=${subscription.subscription_id}`, + }); + + assert.equal(response.statusCode, 200); + assert.equal(response.json().total_matched, 1); + const delivery = response.json().deliveries[0]; + assert.equal(delivery.delivery_state, 'FAILED'); + assert.equal(delivery.terminal_reason, 'MAX_ATTEMPTS_EXHAUSTED'); + assert.equal(delivery.failure_category, 'HTTP_RESPONSE'); + assert.ok(delivery.dead_lettered_at); + return delivery; + }, { timeoutMs: 2500, intervalMs: 25 }); + + const statsResponse = await app.inject({ + method: 'GET', + url: `/webhook-deliveries/stats?subscription_id=${subscription.subscription_id}`, + }); + + assert.equal(statsResponse.statusCode, 200); + assert.equal(statsResponse.json().delivery_guarantee, 'AT_LEAST_ONCE_BEST_EFFORT'); + assert.deepEqual(statsResponse.json().retry_schedule_ms, [10]); + assert.equal(statsResponse.json().state_counts.FAILED, 1); + assert.equal(statsResponse.json().dead_letter_count, 1); + assert.equal(attempts >= 2, true); + assert.equal(deadLetterDelivery.delivery_guarantee, 'AT_LEAST_ONCE_BEST_EFFORT'); + + await app.close(); +}); + +test('reporting notifications are created when an instruction reaches settlement milestones', async () => { + const app = await buildApp(); + + const createResponse = await app.inject({ + method: 'POST', + url: '/instruction', + payload: buildInstructionPayload({ + payment_identification: { + end_to_end_identification: 'INV-051', + }, + charge_bearer: 'DEBT', + debtor: { + name: 'Acme Trading GmbH', + lei: '529900T8BM49AURSDO55', + }, + debtor_account: { + proxy: { identification: '0xdebtoracct' }, + }, + debtor_agent: { + name: 'Bitvavo B.V.', + lei: '7245007VX57GR4IUVZ79', + }, + creditor: { + name: 'Bravo Supplies B.V.', + lei: '724500QHKL6MVSQQ1Z17', + }, + creditor_account: { + proxy: { identification: '0xcreditoracct' }, + }, + creditor_agent: { + name: 'Kraken Belgium BVBA', + lei: '635400DUFB71VFOHVB49', + }, + interbank_settlement_amount: { + amount: '4250.00', + currency: 'USD', + }, + remittance_information: { + unstructured: 'INV-2025-777 / Supplier payment', + }, + blockchain_instruction: { + token: { + token_symbol: 'USDC', + token_dti: 'T9B3X8H2K', + }, + chain_dli: 'X9J9XDMTD', + custody_model: 'FULL_CUSTODY', + }, + }), + }); + + assert.equal(createResponse.statusCode, 201); + const instruction = createResponse.json(); + + const initialNotificationsResponse = await app.inject({ + method: 'GET', + url: `/reporting/notifications?instruction_id=${instruction.instruction_id}`, + }); + + assert.equal(initialNotificationsResponse.statusCode, 200); + assert.equal(initialNotificationsResponse.json().total_matched, 0); + + const currentRecord = app.store.getInstruction(instruction.instruction_id); + const agedTimestamp = new Date(Date.now() - 12000).toISOString(); + app.store.saveInstruction({ + ...currentRecord, + created_at: agedTimestamp, + updated_at: agedTimestamp, + }); + + const statusResponse = await app.inject({ + method: 'GET', + url: `/instruction/${instruction.instruction_id}`, + }); + + assert.equal(statusResponse.statusCode, 200); + assert.equal(statusResponse.json().status, 'FINAL'); + + const notificationsResponse = await app.inject({ + method: 'GET', + url: `/reporting/notifications?instruction_id=${instruction.instruction_id}`, + }); + + assert.equal(notificationsResponse.statusCode, 200); + assert.equal(notificationsResponse.json().total_matched, 2); + assert.equal( + notificationsResponse.json().notifications.some( + (notification) => + notification.entry_type === 'DEBIT' && + notification.account_role === 'DEBTOR', + ), + true, + ); + assert.equal( + notificationsResponse.json().notifications.some( + (notification) => + notification.entry_type === 'CREDIT' && + notification.account_role === 'CREDITOR', + ), + true, + ); + + const debitNotification = notificationsResponse.json().notifications.find( + (notification) => notification.entry_type === 'DEBIT', + ); + + const debitNotificationDetailResponse = await app.inject({ + method: 'GET', + url: `/reporting/notifications/${debitNotification.notification_id}`, + }); + + assert.equal(debitNotificationDetailResponse.statusCode, 200); + assert.equal(debitNotificationDetailResponse.json().party.wallet_address, '0xdebtoracct'); + assert.equal( + debitNotificationDetailResponse.json().counterparty.wallet_address, + '0xcreditoracct', + ); + assert.equal( + debitNotificationDetailResponse.json().status_reference.trigger_status, + 'BROADCAST', + ); + assert.equal( + debitNotificationDetailResponse.json().remittance_information.unstructured, + 'INV-2025-777 / Supplier payment', + ); + + const filteredNotificationsResponse = await app.inject({ + method: 'GET', + url: `/reporting/notifications?instruction_id=${instruction.instruction_id}&entry_type=DEBIT`, + }); + + assert.equal(filteredNotificationsResponse.statusCode, 200); + assert.equal(filteredNotificationsResponse.json().total_matched, 1); + + await app.close(); +}); + +test('reporting notifications are emitted through outbox and webhook delivery', async () => { + const deliveriesReceived = []; + const app = await buildApp({ + webhookSender: async ({ headers, body }) => { + deliveriesReceived.push({ headers, body }); + return { + status: 200, + bodyText: 'ok', + }; + }, + }); + + const subscriptionResponse = await app.inject({ + method: 'POST', + url: '/webhook-endpoints', + payload: { + url: 'https://receiver.example/reporting', + signing_secret: 'whsec_reporting_123456', + subscribed_event_types: ['reporting_notification.created'], + }, + }); + + assert.equal(subscriptionResponse.statusCode, 201); + const subscription = subscriptionResponse.json(); + + const createResponse = await app.inject({ + method: 'POST', + url: '/instruction', + payload: buildInstructionPayload({ + payment_identification: { + end_to_end_identification: 'INV-052', + }, + debtor: { name: 'Acme Trading GmbH' }, + debtor_agent: { + name: 'Bankhaus Example AG', + lei: '7245007VX57GR4IUVZ79', + }, + debtor_account: { + proxy: { identification: '0xreportdebit' }, + }, + creditor: { name: 'Bravo Supplies B.V.' }, + creditor_agent: { + name: 'Kraken Belgium BVBA', + lei: '635400DUFB71VFOHVB49', + }, + creditor_account: { + proxy: { identification: '0xreportcredit' }, + }, + interbank_settlement_amount: { + amount: '6400.00', + currency: 'USD', + }, + blockchain_instruction: { + token: { + token_symbol: 'USDC', + token_dti: 'T9B3X8H2K', + }, + chain_dli: 'X9J9XDMTD', + custody_model: 'FULL_CUSTODY', + }, + }), + }); + + assert.equal(createResponse.statusCode, 201); + const instruction = createResponse.json(); + + const currentRecord = app.store.getInstruction(instruction.instruction_id); + const agedTimestamp = new Date(Date.now() - 7000).toISOString(); + app.store.saveInstruction({ + ...currentRecord, + created_at: agedTimestamp, + updated_at: agedTimestamp, + }); + + const statusResponse = await app.inject({ + method: 'GET', + url: `/instruction/${instruction.instruction_id}`, + }); + + assert.equal(statusResponse.statusCode, 200); + assert.equal(statusResponse.json().status, 'FINAL'); + + const outboxResponse = await app.inject({ + method: 'GET', + url: `/event-outbox?instruction_id=${instruction.instruction_id}&event_type=reporting_notification.created`, + }); + + assert.equal(outboxResponse.statusCode, 200); + assert.equal(outboxResponse.json().total_matched, 2); + + const dispatchResponse = await app.inject({ + method: 'POST', + url: '/webhook-deliveries/dispatch', + payload: { + subscription_id: subscription.subscription_id, + }, + }); + + assert.equal(dispatchResponse.statusCode, 200); + assert.equal(dispatchResponse.json().dispatched_count, 2); + assert.equal(deliveriesReceived.length, 2); + + const envelope = JSON.parse(deliveriesReceived[0].body); + assert.equal( + deliveriesReceived[0].headers['x-pacscrypto-payload-mode'], + 'event_envelope', + ); + assert.equal(envelope.event_type, 'reporting_notification.created'); + assert.ok(envelope.payload.notification_id); + + const deliveredResponse = await app.inject({ + method: 'GET', + url: `/webhook-endpoints/${subscription.subscription_id}/deliveries?delivery_state=DELIVERED`, + }); + + assert.equal(deliveredResponse.statusCode, 200); + assert.equal(deliveredResponse.json().total_matched, 2); + + await app.close(); +}); + +test('intraday reporting view summarizes booked movements and supports account filters', async () => { + const app = await buildApp(); + + const createResponse = await app.inject({ + method: 'POST', + url: '/instruction', + payload: buildInstructionPayload({ + payment_identification: { + end_to_end_identification: 'INV-053', + }, + debtor: { + name: 'Acme Trading GmbH', + lei: '529900T8BM49AURSDO55', + }, + debtor_account: { + proxy: { identification: '0xintradaydebit' }, + }, + creditor: { + name: 'Bravo Supplies B.V.', + lei: '724500QHKL6MVSQQ1Z17', + }, + creditor_account: { + proxy: { identification: '0xintradaycredit' }, + }, + interbank_settlement_amount: { + amount: '5100.00', + currency: 'USD', + }, + blockchain_instruction: { + token: { + token_symbol: 'USDC', + token_dti: 'T9B3X8H2K', + }, + chain_dli: 'X9J9XDMTD', + custody_model: 'FULL_CUSTODY', + }, + }), + }); + + assert.equal(createResponse.statusCode, 201); + const instruction = createResponse.json(); + const currentRecord = app.store.getInstruction(instruction.instruction_id); + const agedTimestamp = new Date(Date.now() - 7000).toISOString(); + app.store.saveInstruction({ + ...currentRecord, + created_at: agedTimestamp, + updated_at: agedTimestamp, + }); + + const statusResponse = await app.inject({ + method: 'GET', + url: `/instruction/${instruction.instruction_id}`, + }); + + assert.equal(statusResponse.statusCode, 200); + assert.equal(statusResponse.json().status, 'FINAL'); + + const intradayResponse = await app.inject({ + method: 'GET', + url: `/reporting/intraday?instruction_id=${instruction.instruction_id}`, + }); + + assert.equal(intradayResponse.statusCode, 200); + assert.equal(intradayResponse.json().movement_summary.notification_count, 2); + assert.equal(intradayResponse.json().movement_summary.debit_count, 1); + assert.equal(intradayResponse.json().movement_summary.credit_count, 1); + assert.equal(intradayResponse.json().account_views.length, 2); + assert.equal(intradayResponse.json().movement_summary.totals[0].currency, 'USD'); + assert.equal( + intradayResponse.json().traceability.instruction_id, + instruction.instruction_id, + ); + + const debtorOnlyResponse = await app.inject({ + method: 'GET', + url: `/reporting/intraday?instruction_id=${instruction.instruction_id}&account_role=DEBTOR`, + }); + + assert.equal(debtorOnlyResponse.statusCode, 200); + assert.equal(debtorOnlyResponse.json().movement_summary.notification_count, 1); + assert.equal(debtorOnlyResponse.json().account_views.length, 1); + assert.equal( + debtorOnlyResponse.json().account_views[0].wallet_address, + '0xintradaydebit', + ); + assert.equal( + debtorOnlyResponse.json().account_views[0].instruction_ids[0], + instruction.instruction_id, + ); + assert.equal( + debtorOnlyResponse.json().movement_summary.totals[0].net_total, + '-5100', + ); + + await app.close(); +}); + +test('statement reporting derives persisted account statements from reporting notifications', async () => { + const app = await buildApp(); + + const createResponse = await app.inject({ + method: 'POST', + url: '/instruction', + payload: buildInstructionPayload({ + payment_identification: { + end_to_end_identification: 'INV-054', + }, + debtor: { + name: 'Acme Trading GmbH', + lei: '529900T8BM49AURSDO55', + }, + debtor_account: { + proxy: { identification: '0xstatementdebit' }, + }, + creditor: { + name: 'Bravo Supplies B.V.', + lei: '724500QHKL6MVSQQ1Z17', + }, + creditor_account: { + proxy: { identification: '0xstatementcredit' }, + }, + interbank_settlement_amount: { + amount: '5100.00', + currency: 'USD', + }, + blockchain_instruction: { + token: { + token_symbol: 'USDC', + token_dti: 'T9B3X8H2K', + }, + chain_dli: 'X9J9XDMTD', + custody_model: 'FULL_CUSTODY', + }, + }), + }); + + assert.equal(createResponse.statusCode, 201); + const instruction = createResponse.json(); + const currentRecord = app.store.getInstruction(instruction.instruction_id); + const agedTimestamp = new Date(Date.now() - 7000).toISOString(); + app.store.saveInstruction({ + ...currentRecord, + created_at: agedTimestamp, + updated_at: agedTimestamp, + }); + + const statusResponse = await app.inject({ + method: 'GET', + url: `/instruction/${instruction.instruction_id}`, + }); + + assert.equal(statusResponse.statusCode, 200); + assert.equal(statusResponse.json().status, 'FINAL'); + + const statementsResponse = await app.inject({ + method: 'GET', + url: `/reporting/statements?instruction_id=${instruction.instruction_id}`, + }); + + assert.equal(statementsResponse.statusCode, 200); + assert.equal(statementsResponse.json().total_matched, 2); + assert.equal(statementsResponse.json().statements.length, 2); + + const debtorStatement = statementsResponse.json().statements.find( + (statement) => statement.account_role === 'DEBTOR', + ); + + assert.ok(debtorStatement); + assert.equal(debtorStatement.balance_summary.closing_balance.amount, '5100'); + assert.equal( + debtorStatement.balance_summary.closing_balance.credit_debit_indicator, + 'DBIT', + ); + assert.equal(debtorStatement.movement_summary.entry_count, 1); + assert.equal(debtorStatement.instruction_context.finality_status, 'FINAL'); + assert.equal(debtorStatement.statement_scope.source_notification_count, 1); + + const debtorStatementDetailResponse = await app.inject({ + method: 'GET', + url: `/reporting/statements/${debtorStatement.statement_id}`, + }); + + assert.equal(debtorStatementDetailResponse.statusCode, 200); + assert.equal(debtorStatementDetailResponse.json().entries.length, 1); + assert.equal(debtorStatementDetailResponse.json().entries[0].entry_type, 'DEBIT'); + assert.equal( + debtorStatementDetailResponse.json().statement_scope.derivation_basis, + 'BOOKED_NOTIFICATIONS', + ); + + const filteredStatementsResponse = await app.inject({ + method: 'GET', + url: `/reporting/statements?instruction_id=${instruction.instruction_id}&account_role=DEBTOR&wallet_address=0xstatementdebit`, + }); + + assert.equal(filteredStatementsResponse.statusCode, 200); + assert.equal(filteredStatementsResponse.json().total_matched, 1); + assert.equal(filteredStatementsResponse.json().statements[0].statement_id, debtorStatement.statement_id); + + await app.close(); +}); + +test('reporting records expose traceability links back to instruction and travel rule records', async () => { + const app = await buildApp(); + + const travelRuleResponse = await app.inject({ + method: 'POST', + url: '/travel-rule', + payload: buildTravelRuleSubmission({ + travel_rule_data: { + payment_identification: { + end_to_end_identification: 'E2E-REPORT-TRACE-001', + }, + }, + }), + }); + + assert.equal(travelRuleResponse.statusCode, 201); + const travelRuleRecord = travelRuleResponse.json(); + + const instructionResponse = await app.inject({ + method: 'POST', + url: '/instruction', + payload: buildInstructionPayload({ + payment_identification: { + end_to_end_identification: 'INV-REPORT-TRACE-001', + }, + travel_rule_record_id: travelRuleRecord.record_id, + debtor_account: { + proxy: { identification: '0xtraceabilitydebit' }, + }, + creditor_account: { + proxy: { identification: '0xtraceabilitycredit' }, + }, + interbank_settlement_amount: { + amount: '1800.00', + currency: 'USD', + }, + }), + }); + + assert.equal(instructionResponse.statusCode, 201); + const instruction = instructionResponse.json(); + const currentRecord = app.store.getInstruction(instruction.instruction_id); + const agedTimestamp = new Date(Date.now() - 7000).toISOString(); + app.store.saveInstruction({ + ...currentRecord, + created_at: agedTimestamp, + updated_at: agedTimestamp, + }); + + const statusResponse = await app.inject({ + method: 'GET', + url: `/instruction/${instruction.instruction_id}`, + }); + + assert.equal(statusResponse.statusCode, 200); + assert.equal(statusResponse.json().status, 'FINAL'); + + const notificationsResponse = await app.inject({ + method: 'GET', + url: `/reporting/notifications?instruction_id=${instruction.instruction_id}`, + }); + + assert.equal(notificationsResponse.statusCode, 200); + assert.equal(notificationsResponse.json().total_matched, 2); + const notification = notificationsResponse.json().notifications[0]; + assert.equal(notification.travel_rule_record_id, travelRuleRecord.record_id); + assert.equal( + notification.traceability.resource_paths.travel_rule_record, + `/travel-rule/${travelRuleRecord.record_id}`, + ); + + const notificationDetailResponse = await app.inject({ + method: 'GET', + url: `/reporting/notifications/${notification.notification_id}`, + }); + + assert.equal(notificationDetailResponse.statusCode, 200); + assert.equal( + notificationDetailResponse.json().traceability.instruction_id, + instruction.instruction_id, + ); + assert.equal( + notificationDetailResponse.json().traceability.travel_rule_record_id, + travelRuleRecord.record_id, + ); + + const statementsResponse = await app.inject({ + method: 'GET', + url: `/reporting/statements?instruction_id=${instruction.instruction_id}`, + }); + + assert.equal(statementsResponse.statusCode, 200); + assert.equal(statementsResponse.json().total_matched, 2); + const statement = statementsResponse.json().statements[0]; + assert.equal(statement.travel_rule_record_id, travelRuleRecord.record_id); + assert.equal(statement.statement_scope.source_notification_count, 1); + + const statementDetailResponse = await app.inject({ + method: 'GET', + url: `/reporting/statements/${statement.statement_id}`, + }); + + assert.equal(statementDetailResponse.statusCode, 200); + assert.equal( + statementDetailResponse.json().traceability.resource_paths.reporting_statement, + `/reporting/statements/${statement.statement_id}`, + ); + assert.equal( + statementDetailResponse.json().traceability.resource_paths.travel_rule_record, + `/travel-rule/${travelRuleRecord.record_id}`, + ); + assert.equal( + statementDetailResponse.json().statement_scope.source_notification_ids.length, + 1, + ); + + await app.close(); +}); + +test('report spec search validates required wallet and chain filters', async () => { + const app = await buildApp(); + + const response = await app.inject({ + method: 'GET', + url: '/report/search?chain_dli=bad&from_date_time=not-a-date&page_size=999', + }); + + assert.equal(response.statusCode, 400); + assert.equal(response.json().code, 'INVALID_REQUEST'); + assert.equal( + response.json().details.some((detail) => detail.field === 'wallet_address'), + true, + ); + assert.equal( + response.json().details.some((detail) => detail.field === 'chain_dli'), + true, + ); + assert.equal( + response.json().details.some((detail) => detail.field === 'from_date_time'), + true, + ); + assert.equal( + response.json().details.some((detail) => detail.field === 'page_size'), + true, + ); + + await app.close(); +}); + +test('report spec paths expose search stats intraday statement and notification detail', async () => { + const app = await buildApp(); + + const createResponse = await app.inject({ + method: 'POST', + url: '/instruction', + payload: buildInstructionPayload({ + payment_identification: { + end_to_end_identification: 'INV-REPORT-SPEC-001', + }, + debtor: { + name: 'Acme Trading GmbH', + lei: '529900T8BM49AURSDO55', + }, + debtor_account: { + proxy: { identification: '0xreportspecdebit' }, + }, + creditor: { + name: 'Bravo Supplies B.V.', + lei: '724500QHKL6MVSQQ1Z17', + }, + creditor_account: { + proxy: { identification: '0xreportspeccredit' }, + }, + interbank_settlement_amount: { + amount: '8800.00', + currency: 'USD', + }, + blockchain_instruction: { + token: { + token_symbol: 'USDC', + token_dti: 'T9B3X8H2K', + }, + chain_dli: 'X9J9XDMTD', + custody_model: 'FULL_CUSTODY', + }, + }), + }); + + assert.equal(createResponse.statusCode, 201); + const instruction = createResponse.json(); + const currentRecord = app.store.getInstruction(instruction.instruction_id); + const agedTimestamp = new Date(Date.now() - 7000).toISOString(); + app.store.saveInstruction({ + ...currentRecord, + created_at: agedTimestamp, + updated_at: agedTimestamp, + }); + + const statusResponse = await app.inject({ + method: 'GET', + url: `/instruction/${instruction.instruction_id}`, + }); + + assert.equal(statusResponse.statusCode, 200); + assert.equal(statusResponse.json().status, 'FINAL'); + + const searchResponse = await app.inject({ + method: 'GET', + url: `/report/search?wallet_address=0xreportspecdebit&chain_dli=X9J9XDMTD&instruction_id=${instruction.instruction_id}&credit_debit_indicator=DBIT`, + }); + + assert.equal(searchResponse.statusCode, 200); + assertReportEntrySearchResponseShape(searchResponse.json()); + assert.equal(searchResponse.json().total_matched, 1); + assert.equal(searchResponse.json().entries[0].credit_debit_indicator, 'DBIT'); + assert.equal(searchResponse.json().entries[0].instruction_id, instruction.instruction_id); + + const statsResponse = await app.inject({ + method: 'GET', + url: '/report/stats?wallet_address=0xreportspecdebit&chain_dli=X9J9XDMTD&from_date_time=2025-01-01T00:00:00Z&to_date_time=2030-01-01T00:00:00Z&group_by=credit_debit', + }); + + assert.equal(statsResponse.statusCode, 200); + assertReportStatsResponseShape(statsResponse.json()); + assert.equal(statsResponse.json().totals.length, 1); + assert.equal(statsResponse.json().totals[0].total_entries, 1); + assert.equal(statsResponse.json().breakdown[0].dimension_value, 'DBIT'); + + const intradayResponse = await app.inject({ + method: 'GET', + url: '/report/intraday?wallet_address=0xreportspecdebit&chain_dli=X9J9XDMTD', + }); + + assert.equal(intradayResponse.statusCode, 200); + assertReportIntradayShape(intradayResponse.json()); + assert.equal(intradayResponse.json().report.total_entries, 1); + assert.equal(intradayResponse.json().report.account.identification.proxy.identification, '0xreportspecdebit'); + assert.equal(intradayResponse.json().report.entries[0].credit_debit_indicator, 'DBIT'); + + const statementResponse = await app.inject({ + method: 'GET', + url: `/report/statement?wallet_address=0xreportspecdebit&chain_dli=X9J9XDMTD&from_date=${agedTimestamp.slice(0, 10)}&to_date=${agedTimestamp.slice(0, 10)}`, + }); + + assert.equal(statementResponse.statusCode, 200); + assertReportStatementShape(statementResponse.json()); + assert.equal(statementResponse.json().statement.total_entries, 1); + assert.equal(statementResponse.json().statement.account.identification.proxy.identification, '0xreportspecdebit'); + + const legacyNotificationsResponse = await app.inject({ + method: 'GET', + url: `/reporting/notifications?instruction_id=${instruction.instruction_id}&wallet_address=0xreportspecdebit`, + }); + const notificationId = legacyNotificationsResponse.json().notifications[0].notification_id; + + const notificationDetailResponse = await app.inject({ + method: 'GET', + url: `/report/notification/${notificationId}`, + }); + + assert.equal(notificationDetailResponse.statusCode, 200); + assertBlockchainNotificationShape(notificationDetailResponse.json()); + assert.equal( + notificationDetailResponse.json().entry.blockchain_detail.instruction_id, + instruction.instruction_id, + ); + assert.equal( + notificationDetailResponse.json().account.identification.proxy.identification, + '0xreportspecdebit', + ); + + const queryBalanceResponse = await app.inject({ + method: 'POST', + url: '/report/query', + payload: { + query_identification: 'QRY-REPORT-BAL-001', + query_type: 'BALANCE', + account: { + identification: { + proxy: { + identification: '0xreportspecdebit', + }, + }, + type: { + proprietary: 'DLID/X9J9XDMTD', + }, + }, + }, + }); + + assert.equal(queryBalanceResponse.statusCode, 201); + assert.equal(queryBalanceResponse.json().query_type, 'BALANCE'); + assert.ok(Array.isArray(queryBalanceResponse.json().balances)); + assert.equal(queryBalanceResponse.json().balances.length, 1); + assert.equal(queryBalanceResponse.json().balances[0].balance_type, 'ITBD'); + assert.equal(queryBalanceResponse.json().balances[0].token_amount.amount, '8800'); + assert.equal(queryBalanceResponse.json().balances[0].credit_debit_indicator, 'DBIT'); + + const queryIntradayResponse = await app.inject({ + method: 'POST', + url: '/report/query', + payload: { + query_identification: 'QRY-REPORT-INTRA-001', + query_type: 'INTRADAY', + account: { + identification: { + proxy: { + identification: '0xreportspecdebit', + }, + }, + type: { + proprietary: 'DLID/X9J9XDMTD', + }, + }, + reporting_period: { + from_date_time: `${agedTimestamp.slice(0, 10)}T00:00:00Z`, + to_date_time: `${agedTimestamp.slice(0, 10)}T23:59:59Z`, + }, + }, + }); + + assert.equal(queryIntradayResponse.statusCode, 201); + assertReportIntradayShape(queryIntradayResponse.json().intraday_report); + + const queryStatementResponse = await app.inject({ + method: 'POST', + url: '/report/query', + payload: { + query_identification: 'QRY-REPORT-STMT-001', + query_type: 'STATEMENT', + account: { + identification: { + proxy: { + identification: '0xreportspecdebit', + }, + }, + type: { + proprietary: 'DLID/X9J9XDMTD', + }, + }, + reporting_period: { + from_date_time: `${agedTimestamp.slice(0, 10)}T00:00:00Z`, + to_date_time: `${agedTimestamp.slice(0, 10)}T23:59:59Z`, + }, + }, + }); + + assert.equal(queryStatementResponse.statusCode, 201); + assertReportStatementShape(queryStatementResponse.json().statement); + + await app.close(); +}); + +test('report query notification subscriptions are wallet-scoped and can be cancelled', async () => { + const app = await buildApp(); + + const subscribeResponse = await app.inject({ + method: 'POST', + url: '/report/query', + payload: { + query_identification: 'QRY-REPORT-SUB-001', + query_type: 'NOTIFICATION_SUBSCRIBE', + account: { + identification: { + proxy: { + identification: '0xreportsubdebit', + }, + }, + type: { + proprietary: 'DLID/X9J9XDMTD', + }, + }, + callback_url: 'https://bank.example.com/webhooks/blockchain-notifications', + }, + }); + + assert.equal(subscribeResponse.statusCode, 201); + assert.equal(subscribeResponse.json().subscription_status, 'ACTIVE'); + const subscriptionId = subscribeResponse.json().subscription_id; + + const duplicateSubscribeResponse = await app.inject({ + method: 'POST', + url: '/report/query', + payload: { + query_identification: 'QRY-REPORT-SUB-002', + query_type: 'NOTIFICATION_SUBSCRIBE', + account: { + identification: { + proxy: { + identification: '0xreportsubdebit', + }, + }, + type: { + proprietary: 'DLID/X9J9XDMTD', + }, + }, + callback_url: 'https://bank.example.com/webhooks/blockchain-notifications', + }, + }); + + assert.equal(duplicateSubscribeResponse.statusCode, 409); + assert.equal(duplicateSubscribeResponse.json().subscription_id, subscriptionId); + + const createResponse = await app.inject({ + method: 'POST', + url: '/instruction', + payload: buildInstructionPayload({ + payment_identification: { + end_to_end_identification: 'INV-REPORT-SUB-001', + }, + debtor_account: { + proxy: { identification: '0xreportsubdebit' }, + }, + creditor_account: { + proxy: { identification: '0xreportsubcredit' }, + }, + interbank_settlement_amount: { + amount: '9100.00', + currency: 'USD', + }, + }), + }); + + assert.equal(createResponse.statusCode, 201); + const instruction = createResponse.json(); + const currentRecord = app.store.getInstruction(instruction.instruction_id); + const agedTimestamp = new Date(Date.now() - 7000).toISOString(); + app.store.saveInstruction({ + ...currentRecord, + created_at: agedTimestamp, + updated_at: agedTimestamp, + }); + + const statusResponse = await app.inject({ + method: 'GET', + url: `/instruction/${instruction.instruction_id}`, + }); + + assert.equal(statusResponse.statusCode, 200); + assert.equal(statusResponse.json().status, 'FINAL'); + + const deliveriesResponse = await app.inject({ + method: 'GET', + url: `/webhook-endpoints/${subscriptionId}/deliveries?event_type=reporting_notification.created`, + }); + + assert.equal(deliveriesResponse.statusCode, 200); + assert.equal(deliveriesResponse.json().total_matched, 1); + assert.equal( + deliveriesResponse.json().deliveries[0].event_type, + 'reporting_notification.created', + ); + + const unsubscribeResponse = await app.inject({ + method: 'POST', + url: '/report/query', + payload: { + query_identification: 'QRY-REPORT-UNSUB-001', + query_type: 'NOTIFICATION_UNSUBSCRIBE', + account: { + identification: { + proxy: { + identification: '0xreportsubdebit', + }, + }, + type: { + proprietary: 'DLID/X9J9XDMTD', + }, + }, + subscription_id: subscriptionId, + }, + }); + + assert.equal(unsubscribeResponse.statusCode, 201); + assert.equal(unsubscribeResponse.json().subscription_status, 'CANCELLED'); + + const subscriptionLookupResponse = await app.inject({ + method: 'GET', + url: `/webhook-endpoints/${subscriptionId}`, + }); + + assert.equal(subscriptionLookupResponse.statusCode, 200); + assert.equal(subscriptionLookupResponse.json().active, false); + + await app.close(); +}); + +test('report query notification subscriptions deliver raw spec notification payloads', async () => { + const deliveriesReceived = []; + const app = await buildApp({ + webhookSender: async ({ headers, body }) => { + deliveriesReceived.push({ headers, body }); + return { + status: 200, + bodyText: 'ok', + }; + }, + }); + + const subscribeResponse = await app.inject({ + method: 'POST', + url: '/report/query', + payload: { + query_identification: 'QRY-REPORT-SUB-RAW-001', + query_type: 'NOTIFICATION_SUBSCRIBE', + account: { + identification: { + proxy: { + identification: '0xreportrawdebit', + }, + }, + type: { + proprietary: 'DLID/X9J9XDMTD', + }, + }, + callback_url: 'https://bank.example.com/webhooks/spec-notifications', + }, + }); + + assert.equal(subscribeResponse.statusCode, 201); + const subscriptionId = subscribeResponse.json().subscription_id; + + const createResponse = await app.inject({ + method: 'POST', + url: '/instruction', + payload: buildInstructionPayload({ + payment_identification: { + end_to_end_identification: 'INV-REPORT-SUB-RAW-001', + }, + debtor_account: { + proxy: { identification: '0xreportrawdebit' }, + }, + creditor_account: { + proxy: { identification: '0xreportrawcredit' }, + }, + interbank_settlement_amount: { + amount: '9150.00', + currency: 'USD', + }, + }), + }); + + assert.equal(createResponse.statusCode, 201); + const instruction = createResponse.json(); + const currentRecord = app.store.getInstruction(instruction.instruction_id); + const agedTimestamp = new Date(Date.now() - 7000).toISOString(); + app.store.saveInstruction({ + ...currentRecord, + created_at: agedTimestamp, + updated_at: agedTimestamp, + }); + + const statusResponse = await app.inject({ + method: 'GET', + url: `/instruction/${instruction.instruction_id}`, + }); + + assert.equal(statusResponse.statusCode, 200); + assert.equal(statusResponse.json().status, 'FINAL'); + + const dispatchResponse = await app.inject({ + method: 'POST', + url: '/webhook-deliveries/dispatch', + payload: { + subscription_id: subscriptionId, + }, + }); + + assert.equal(dispatchResponse.statusCode, 200); + assert.equal(dispatchResponse.json().dispatched_count, 1); + assert.equal(deliveriesReceived.length, 1); + + const notification = JSON.parse(deliveriesReceived[0].body); + assert.equal( + deliveriesReceived[0].headers['x-pacscrypto-payload-mode'], + 'spec_message', + ); + assertBlockchainNotificationShape(notification); + assert.equal( + notification.account.identification.proxy.identification, + '0xreportrawdebit', + ); + assert.equal( + notification.entry.blockchain_detail.instruction_id, + instruction.instruction_id, + ); + assert.equal(notification.notification_type, 'ENTRY_FINAL'); + assert.equal(notification.entry.entry_status, 'BOOK'); + + await app.close(); +}); + +test('report query statements can be delivered asynchronously via callback_url', async () => { + const deliveriesReceived = []; + const app = await buildApp({ + webhookSender: async ({ headers, body }) => { + deliveriesReceived.push({ headers, body }); + return { + status: 200, + bodyText: 'ok', + }; + }, + }); + + const createResponse = await app.inject({ + method: 'POST', + url: '/instruction', + payload: buildInstructionPayload({ + payment_identification: { + end_to_end_identification: 'INV-REPORT-STMT-ASYNC-001', + }, + debtor_account: { + proxy: { identification: '0xreportstmtasyncdebit' }, + }, + creditor_account: { + proxy: { identification: '0xreportstmtasynccredit' }, + }, + interbank_settlement_amount: { + amount: '9050.00', + currency: 'USD', + }, + }), + }); + + assert.equal(createResponse.statusCode, 201); + const instruction = createResponse.json(); + const currentRecord = app.store.getInstruction(instruction.instruction_id); + const agedTimestamp = new Date(Date.now() - 7000).toISOString(); + app.store.saveInstruction({ + ...currentRecord, + created_at: agedTimestamp, + updated_at: agedTimestamp, + }); + + const statusResponse = await app.inject({ + method: 'GET', + url: `/instruction/${instruction.instruction_id}`, + }); + + assert.equal(statusResponse.statusCode, 200); + assert.equal(statusResponse.json().status, 'FINAL'); + + const statementQueryResponse = await app.inject({ + method: 'POST', + url: '/report/query', + payload: { + query_identification: 'QRY-REPORT-STMT-ASYNC-001', + query_type: 'STATEMENT', + account: { + identification: { + proxy: { + identification: '0xreportstmtasyncdebit', + }, + }, + type: { + proprietary: 'DLID/X9J9XDMTD', + }, + }, + reporting_period: { + from_date_time: `${agedTimestamp.slice(0, 10)}T00:00:00Z`, + to_date_time: `${agedTimestamp.slice(0, 10)}T23:59:59Z`, + }, + callback_url: 'https://bank.example.com/webhooks/statements', + }, + }); + + assert.equal(statementQueryResponse.statusCode, 202); + assert.equal( + statementQueryResponse.json().query_identification, + 'QRY-REPORT-STMT-ASYNC-001', + ); + + const callbackSubscription = app.store + .listWebhookSubscriptionRecords() + .find( + (record) => + record.subscription_kind === 'REPORT_STATEMENT_CALLBACK' && + record.query_identification === 'QRY-REPORT-STMT-ASYNC-001', + ); + + assert.ok(callbackSubscription); + assert.equal(callbackSubscription.active, true); + + const dispatchResponse = await app.inject({ + method: 'POST', + url: '/webhook-deliveries/dispatch', + payload: { + subscription_id: callbackSubscription.subscription_id, + }, + }); + + assert.equal(dispatchResponse.statusCode, 200); + assert.equal(dispatchResponse.json().dispatched_count, 1); + assert.equal(deliveriesReceived.length, 1); + + const statement = JSON.parse(deliveriesReceived[0].body); + assert.equal( + deliveriesReceived[0].headers['x-pacscrypto-payload-mode'], + 'spec_message', + ); + assert.equal( + deliveriesReceived[0].headers['x-pacscrypto-event-type'], + 'reporting_statement.ready', + ); + assertReportStatementShape(statement); + assert.equal( + statement.statement.account.identification.proxy.identification, + '0xreportstmtasyncdebit', + ); + assert.equal(statement.statement.total_entries, 1); + + const callbackSubscriptionAfter = app.store.getWebhookSubscriptionRecord( + callbackSubscription.subscription_id, + ); + assert.equal(callbackSubscriptionAfter.active, false); + + await app.close(); +}); + +test('chain adapter can be partially injected without changing route contracts', async () => { + const baseAdapter = createMockEvmChainAdapter(); + const customAdapter = { + id: 'testnet-ready-mock', + mode: 'TESTNET_READY', + buildQuoteResponse: baseAdapter.buildQuoteResponse, + describeLifecycle(input) { + const metadata = baseAdapter.describeLifecycle(input); + return { + ...metadata, + adapter_id: 'testnet-ready-mock', + adapter_mode: 'TESTNET_READY', + lifecycle_policy: { + ...metadata.lifecycle_policy, + required_confirmation_depth: 9, + }, + }; + }, + deriveLifecycleState(record) { + const lifecycle = baseAdapter.deriveLifecycleState(record); + if (record.status === 'PENDING' && Date.now() - Date.parse(record.created_at) >= 1000) { + return { + status: 'BROADCAST', + failureReason: null, + onChainSettlement: { + ...lifecycle.onChainSettlement, + transaction_hash: '0xadapter00000000000000000000000000000000000000000000000000000000', + required_confirmation_depth: 9, + }, + }; + } + return lifecycle; + }, + }; + + const app = await buildApp({ chainAdapter: customAdapter }); + + const quoteResponse = await app.inject({ + method: 'POST', + url: '/instruction/quote', + payload: buildQuoteRequest(), + }); + + assert.equal(quoteResponse.statusCode, 200); + assertAdapterMetadataShape( + quoteResponse.json().adapter_metadata, + 'testnet-ready-mock', + ); + assert.equal( + quoteResponse.json().adapter_metadata.adapter_mode, + 'TESTNET_READY', + ); + + const createResponse = await app.inject({ + method: 'POST', + url: '/instruction', + payload: buildInstructionPayload({ + payment_identification: { + end_to_end_identification: 'INV-ADAPTER-001', + }, + interbank_settlement_amount: { + amount: '900.00', + currency: 'USD', + }, + }), + }); + + assert.equal(createResponse.statusCode, 201); + const instruction = createResponse.json(); + const currentRecord = app.store.getInstruction(instruction.instruction_id); + const agedTimestamp = new Date(Date.now() - 1500).toISOString(); + app.store.saveInstruction({ + ...currentRecord, + created_at: agedTimestamp, + updated_at: agedTimestamp, + }); + + const statusResponse = await app.inject({ + method: 'GET', + url: `/execution-status/${instruction.instruction_id}`, + }); + + assert.equal(statusResponse.statusCode, 200); + assert.equal(statusResponse.json().status, 'BROADCAST'); + assert.equal(statusResponse.json().required_confirmation_depth, 9); + assert.equal( + statusResponse.json().transaction_hash, + '0xadapter00000000000000000000000000000000000000000000000000000000', + ); + assertAdapterMetadataShape( + statusResponse.json().adapter_metadata, + 'testnet-ready-mock', + ); + assert.equal( + statusResponse.json().adapter_metadata.lifecycle_policy.required_confirmation_depth, + 9, + ); + + await app.close(); +}); + +test('Sepolia USDC adapter can run read-only without changing route contracts', async () => { + const app = await buildApp({ + chainAdapter: createSepoliaUsdcAdapter({ + usdcContractAddress: '0x0000000000000000000000000000000000000001', + broadcastEnabled: false, + }), + }); + + const quoteResponse = await app.inject({ + method: 'POST', + url: '/instruction/quote', + payload: buildQuoteRequest({ + amount: '10.00', + }), + }); + + assert.equal(quoteResponse.statusCode, 200); + assertAdapterMetadataShape(quoteResponse.json().adapter_metadata, 'sepolia-usdc'); + assert.equal( + quoteResponse.json().adapter_metadata.adapter_mode, + 'TESTNET_READ_ONLY', + ); + assert.equal(quoteResponse.json().adapter_metadata.simulated, false); + + const createResponse = await app.inject({ + method: 'POST', + url: '/instruction', + payload: buildInstructionPayload({ + payment_identification: { + end_to_end_identification: 'INV-SEPOLIA-READONLY-001', + quote_id: quoteResponse.json().quote_id, + }, + interbank_settlement_amount: { + amount: '10.00', + currency: 'USD', + }, + creditor_account: { + proxy: { + identification: '0x0000000000000000000000000000000000000002', + }, + }, + }), + }); + + assert.equal(createResponse.statusCode, 201); + assert.equal(createResponse.json().status, 'PENDING'); + assertAdapterMetadataShape(createResponse.json().adapter_metadata, 'sepolia-usdc'); + + const statusResponse = await app.inject({ + method: 'GET', + url: `/execution-status/${createResponse.json().instruction_id}`, + }); + + assert.equal(statusResponse.statusCode, 200); + assert.equal(statusResponse.json().status, 'PENDING'); + assert.equal(statusResponse.json().transaction_hash, null); + assert.equal(statusResponse.json().finality_status, 'PENDING'); + assertAdapterMetadataShape(statusResponse.json().adapter_metadata, 'sepolia-usdc'); + + await app.close(); +}); + +test('Sepolia broadcast mode fails safely when execution credentials are incomplete', async () => { + const app = await buildApp({ + chainAdapter: createSepoliaUsdcAdapter({ + usdcContractAddress: '0x0000000000000000000000000000000000000001', + broadcastEnabled: true, + }), + }); + + const createResponse = await app.inject({ + method: 'POST', + url: '/instruction', + payload: buildInstructionPayload({ + payment_identification: { + end_to_end_identification: 'INV-SEPOLIA-SAFEFAIL-001', + }, + interbank_settlement_amount: { + amount: '10.00', + currency: 'USD', + }, + creditor_account: { + proxy: { + identification: '0x0000000000000000000000000000000000000002', + }, + }, + }), + }); + + assert.equal(createResponse.statusCode, 201); + assert.equal(createResponse.json().status, 'FAILED'); + assert.match(createResponse.json().failure_reason, /RPC URL/); + + const statusResponse = await app.inject({ + method: 'GET', + url: `/execution-status/${createResponse.json().instruction_id}`, + }); + + assert.equal(statusResponse.statusCode, 200); + assert.equal(statusResponse.json().status, 'FAILED'); + assert.match(statusResponse.json().failure_reason, /RPC URL/); + assert.equal(statusResponse.json().transaction_hash, null); + assertAdapterMetadataShape(statusResponse.json().adapter_metadata, 'sepolia-usdc'); + + await app.close(); +}); + +test('Sepolia broadcast mode fails safely when RPC is not actually Sepolia', async () => { + const app = await buildApp({ + chainAdapter: createSepoliaUsdcAdapter({ + provider: { + async getNetwork() { + return { chainId: 1n }; + }, + }, + rpcUrl: 'https://rpc.example.invalid', + privateKey: + '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80', + sourceAddress: '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266', + usdcContractAddress: '0x0000000000000000000000000000000000000001', + broadcastEnabled: true, + }), + }); + + const createResponse = await app.inject({ + method: 'POST', + url: '/instruction', + payload: buildInstructionPayload({ + payment_identification: { + end_to_end_identification: 'INV-SEPOLIA-WRONGCHAIN-001', + }, + interbank_settlement_amount: { + amount: '10.00', + currency: 'USD', + }, + creditor_account: { + proxy: { + identification: '0x0000000000000000000000000000000000000002', + }, + }, + }), + }); + + assert.equal(createResponse.statusCode, 201); + assert.equal(createResponse.json().status, 'FAILED'); + assert.match(createResponse.json().failure_reason, /expected Sepolia/i); + + const statusResponse = await app.inject({ + method: 'GET', + url: `/execution-status/${createResponse.json().instruction_id}`, + }); + + assert.equal(statusResponse.statusCode, 200); + assert.equal(statusResponse.json().status, 'FAILED'); + assert.match(statusResponse.json().failure_reason, /expected Sepolia/i); + assert.equal(statusResponse.json().transaction_hash, null); + assertAdapterMetadataShape(statusResponse.json().adapter_metadata, 'sepolia-usdc'); + + await app.close(); +}); + +test('Sepolia broadcast mode rejects debtor wallet mismatch before transfer', async () => { + const sourceAddress = '0x00000000000000000000000000000000000000aa'; + const observedTransfers = []; + const app = await buildApp({ + chainAdapter: createSepoliaUsdcAdapter({ + provider: { + async getNetwork() { + return { chainId: 11155111n, name: 'sepolia' }; + }, + }, + rpcUrl: 'https://rpc.example.invalid', + privateKey: + '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80', + sourceAddress, + usdcContractAddress: '0x0000000000000000000000000000000000000001', + broadcastEnabled: true, + walletFactory() { + return { + address: sourceAddress, + }; + }, + contractFactory() { + return { + async transfer() { + observedTransfers.push(true); + return { hash: `0x${'b'.repeat(64)}` }; + }, + }; + }, + }), + }); + + const createResponse = await app.inject({ + method: 'POST', + url: '/instruction', + payload: buildInstructionPayload({ + payment_identification: { + end_to_end_identification: 'INV-SEPOLIA-SOURCE-MISMATCH-001', + }, + interbank_settlement_amount: { + amount: '10.00', + currency: 'USD', + }, + debtor_account: { + proxy: { + identification: '0x00000000000000000000000000000000000000cc', + }, + }, + creditor_account: { + proxy: { + identification: '0x00000000000000000000000000000000000000bb', + }, + }, + }), + }); + + assert.equal(createResponse.statusCode, 201); + assert.equal(createResponse.json().status, 'FAILED'); + assert.match(createResponse.json().failure_reason, /debtor wallet/i); + assert.equal(observedTransfers.length, 0); + + await app.close(); +}); + +test('Sepolia broadcast mode rejects invalid corridor metadata before transfer', async () => { + const sourceAddress = '0x00000000000000000000000000000000000000aa'; + const observedTransfers = []; + const app = await buildApp({ + chainAdapter: createSepoliaUsdcAdapter({ + provider: { + async getNetwork() { + return { chainId: 11155111n, name: 'sepolia' }; + }, + }, + rpcUrl: 'https://rpc.example.invalid', + privateKey: + '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80', + sourceAddress, + usdcContractAddress: '0x0000000000000000000000000000000000000001', + broadcastEnabled: true, + walletFactory() { + return { + address: sourceAddress, + }; + }, + contractFactory() { + return { + async transfer() { + observedTransfers.push(true); + return { hash: `0x${'c'.repeat(64)}` }; + }, + }; + }, + }), + }); + + const cases = [ + { + endToEndIdentification: 'INV-SEPOLIA-CORRIDOR-CHAIN-001', + overrides: { + blockchain_instruction: { + token: { + token_symbol: 'USDC', + token_dti: 'T9B3X8H2K', + }, + chain_dli: '4H95J0R2X', + custody_model: 'FULL_CUSTODY', + }, + }, + expectedFailure: /chain_dli/i, + }, + { + endToEndIdentification: 'INV-SEPOLIA-CORRIDOR-TOKEN-001', + overrides: { + blockchain_instruction: { + token: { + token_symbol: 'BTC', + token_dti: '4H95J0R2X', + }, + chain_dli: 'X9J9XDMTD', + custody_model: 'FULL_CUSTODY', + }, + }, + expectedFailure: /token_dti/i, + }, + { + endToEndIdentification: 'INV-SEPOLIA-CORRIDOR-CURRENCY-001', + overrides: { + interbank_settlement_amount: { + amount: '10.00', + currency: 'EUR', + }, + }, + expectedFailure: /currency/i, + }, + ]; + + for (const testCase of cases) { + const createResponse = await app.inject({ + method: 'POST', + url: '/instruction', + payload: buildInstructionPayload({ + payment_identification: { + end_to_end_identification: testCase.endToEndIdentification, + }, + interbank_settlement_amount: { + amount: '10.00', + currency: 'USD', + }, + debtor_account: { + proxy: { + identification: sourceAddress, + }, + }, + creditor_account: { + proxy: { + identification: '0x00000000000000000000000000000000000000bb', + }, + }, + ...testCase.overrides, + }), + }); + + assert.equal(createResponse.statusCode, 201); + assert.equal(createResponse.json().status, 'FAILED'); + assert.match(createResponse.json().failure_reason, testCase.expectedFailure); + } + + assert.equal(observedTransfers.length, 0); + + await app.close(); +}); + +test('concurrent duplicate Sepolia submissions do not duplicate the broadcast', async () => { + const transactionHash = `0x${'e'.repeat(64)}`; + const sourceAddress = '0x00000000000000000000000000000000000000aa'; + const recipientAddress = '0x00000000000000000000000000000000000000bb'; + const tokenAddress = '0x0000000000000000000000000000000000000001'; + const observedTransfers = []; + const app = await buildApp({ + chainAdapter: createSepoliaUsdcAdapter({ + provider: { + async getNetwork() { + return { chainId: 11155111n, name: 'sepolia' }; + }, + async getTransactionReceipt() { + return null; + }, + }, + rpcUrl: 'https://rpc.example.invalid', + privateKey: + '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80', + sourceAddress, + usdcContractAddress: tokenAddress, + broadcastEnabled: true, + walletFactory() { + return { + address: sourceAddress, + }; + }, + contractFactory() { + return { + async transfer() { + observedTransfers.push(true); + await new Promise((resolveTransfer) => { + setTimeout(resolveTransfer, 20); + }); + return { + hash: transactionHash, + }; + }, + }; + }, + }), + }); + + const payload = buildInstructionPayload({ + payment_identification: { + end_to_end_identification: 'INV-SEPOLIA-CONCURRENT-001', + }, + interbank_settlement_amount: { + amount: '10.00', + currency: 'USD', + }, + debtor_account: { + proxy: { + identification: sourceAddress, + }, + }, + creditor_account: { + proxy: { + identification: recipientAddress, + }, + }, + }); + + const responses = await Promise.all([ + app.inject({ + method: 'POST', + url: '/instruction', + payload, + }), + app.inject({ + method: 'POST', + url: '/instruction', + payload, + }), + ]); + + assert.equal(observedTransfers.length, 1); + const createdResponses = responses.filter((response) => response.statusCode === 201); + assert.ok(createdResponses.length >= 1); + const instructionId = createdResponses[0].json().instruction_id; + for (const response of responses) { + if (response.statusCode === 201) { + assert.equal(response.json().instruction_id, instructionId); + } else { + assert.equal(response.statusCode, 409); + assert.equal(response.json().instruction_id, instructionId); + } + } + + const statusResponse = await app.inject({ + method: 'GET', + url: `/execution-status/${instructionId}`, + }); + + assert.equal(statusResponse.statusCode, 200); + assert.equal(statusResponse.json().transaction_hash, transactionHash); + + await app.close(); +}); + +test('Sepolia finality fails if the receipt lacks the expected USDC Transfer log', async () => { + const transactionHash = `0x${'d'.repeat(64)}`; + const sourceAddress = '0x00000000000000000000000000000000000000aa'; + const recipientAddress = '0x00000000000000000000000000000000000000bb'; + const tokenAddress = '0x0000000000000000000000000000000000000001'; + const chainState = { + latestBlock: 102, + receipt: { + blockNumber: 100, + gasUsed: 45_000n, + gasPrice: 20_000_000_000n, + status: 1, + logs: [ + buildParsedTransferLog({ + tokenAddress, + from: sourceAddress, + to: '0x00000000000000000000000000000000000000cc', + amountRaw: 10_000_000n, + }), + ], + }, + }; + + const app = await buildApp({ + chainAdapter: createSepoliaUsdcAdapter({ + provider: { + async getNetwork() { + return { chainId: 11155111n, name: 'sepolia' }; + }, + async getTransactionReceipt(hash) { + assert.equal(hash, transactionHash); + return chainState.receipt; + }, + async getBlockNumber() { + return chainState.latestBlock; + }, + async getBlock(blockNumber) { + assert.equal(blockNumber, 100); + return { timestamp: 1_712_345_678 }; + }, + }, + rpcUrl: 'https://rpc.example.invalid', + privateKey: + '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80', + sourceAddress, + usdcContractAddress: tokenAddress, + broadcastEnabled: true, + requiredConfirmations: 3, + walletFactory() { + return { + address: sourceAddress, + }; + }, + contractFactory() { + return { + async transfer() { + return { + hash: transactionHash, + }; + }, + }; + }, + }), + }); + + const createResponse = await app.inject({ + method: 'POST', + url: '/instruction', + payload: buildInstructionPayload({ + payment_identification: { + end_to_end_identification: 'INV-SEPOLIA-LOG-MISMATCH-001', + }, + interbank_settlement_amount: { + amount: '10.00', + currency: 'USD', + }, + debtor_account: { + proxy: { + identification: sourceAddress, + }, + }, + creditor_account: { + proxy: { + identification: recipientAddress, + }, + }, + }), + }); + + assert.equal(createResponse.statusCode, 201); + assert.equal(createResponse.json().status, 'BROADCAST'); + + const statusResponse = await app.inject({ + method: 'GET', + url: `/execution-status/${createResponse.json().instruction_id}`, + }); + + assert.equal(statusResponse.statusCode, 200); + assert.equal(statusResponse.json().status, 'FAILED'); + assert.match(statusResponse.json().failure_reason, /Transfer log/i); + assert.equal(statusResponse.json().finality_status, 'FAILED'); + assert.equal( + statusResponse.json().transfer_verification.verified, + false, + ); + + const finalityReceiptResponse = await app.inject({ + method: 'GET', + url: `/finality-receipt/${createResponse.json().instruction_id}`, + }); + + assert.equal(finalityReceiptResponse.statusCode, 200); + assert.equal(finalityReceiptResponse.json().finality_status, 'FAILED'); + assert.equal(finalityReceiptResponse.json().transfer_verification.verified, false); + + await app.close(); +}); + +test('Sepolia broadcast mode can progress from broadcast to final and surface reporting linkage', async () => { + const transactionHash = `0x${'a'.repeat(64)}`; + const sourceAddress = '0x00000000000000000000000000000000000000aa'; + const recipientAddress = '0x00000000000000000000000000000000000000bb'; + const tokenAddress = '0x0000000000000000000000000000000000000001'; + const observedTransfers = []; + const chainState = { + latestBlock: 0, + receipt: null, + }; + + const app = await buildApp({ + chainAdapter: createSepoliaUsdcAdapter({ + provider: { + async getNetwork() { + return { chainId: 11155111n, name: 'sepolia' }; + }, + async getFeeData() { + return { + maxFeePerGas: 30_000_000_000n, + }; + }, + async getTransactionReceipt(hash) { + assert.equal(hash, transactionHash); + return chainState.receipt; + }, + async getBlockNumber() { + return chainState.latestBlock; + }, + async getBlock(blockNumber) { + assert.equal(blockNumber, 100); + return { timestamp: 1_712_345_678 }; + }, + }, + rpcUrl: 'https://rpc.example.invalid', + privateKey: + '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80', + sourceAddress, + usdcContractAddress: tokenAddress, + broadcastEnabled: true, + requiredConfirmations: 3, + gasLimit: 91000, + walletFactory() { + return { + address: sourceAddress, + }; + }, + contractFactory() { + return { + async transfer(recipient, amount, txOverrides) { + observedTransfers.push({ + recipient, + amount, + txOverrides, + }); + return { + hash: transactionHash, + }; + }, + }; + }, + }), + }); + + const createResponse = await app.inject({ + method: 'POST', + url: '/instruction', + payload: buildInstructionPayload({ + payment_identification: { + end_to_end_identification: 'INV-SEPOLIA-HAPPY-001', + }, + interbank_settlement_amount: { + amount: '10.00', + currency: 'USD', + }, + debtor_account: { + proxy: { + identification: sourceAddress, + }, + }, + creditor_account: { + proxy: { + identification: recipientAddress, + }, + }, + }), + }); + + assert.equal(createResponse.statusCode, 201); + assert.equal(createResponse.json().status, 'BROADCAST'); + assert.equal(observedTransfers.length, 1); + assert.equal(observedTransfers[0].recipient, recipientAddress); + assert.equal(String(observedTransfers[0].amount), '10000000'); + assert.equal(observedTransfers[0].txOverrides.gasLimit, 91000n); + + const broadcastStatusResponse = await app.inject({ + method: 'GET', + url: `/execution-status/${createResponse.json().instruction_id}`, + }); + + assert.equal(broadcastStatusResponse.statusCode, 200); + assert.equal(broadcastStatusResponse.json().status, 'BROADCAST'); + assert.equal(broadcastStatusResponse.json().transaction_hash, transactionHash); + assert.equal(broadcastStatusResponse.json().adapter_metadata.adapter_mode, 'TESTNET_BROADCAST'); + assert.equal(observedTransfers.length, 1); + + const instructionSearchResponse = await app.inject({ + method: 'GET', + url: '/instruction/search?status=BROADCAST&chain_dli=X9J9XDMTD', + }); + + assert.equal(instructionSearchResponse.statusCode, 200); + assert.equal(instructionSearchResponse.json().total_matched, 1); + assert.equal(observedTransfers.length, 1); + + chainState.receipt = { + blockNumber: 100, + gasUsed: 45_000n, + gasPrice: 20_000_000_000n, + status: 1, + logs: [ + buildParsedTransferLog({ + tokenAddress, + from: sourceAddress, + to: recipientAddress, + amountRaw: 10_000_000n, + }), + ], + }; + chainState.latestBlock = 100; + + const confirmingStatusResponse = await app.inject({ + method: 'GET', + url: `/execution-status/${createResponse.json().instruction_id}`, + }); + + assert.equal(confirmingStatusResponse.statusCode, 200); + assert.equal(confirmingStatusResponse.json().status, 'CONFIRMING'); + assert.equal(confirmingStatusResponse.json().finality_status, 'PROBABILISTIC'); + assert.equal(confirmingStatusResponse.json().confirmation_depth, 1); + + chainState.latestBlock = 102; + + const finalStatusResponse = await app.inject({ + method: 'GET', + url: `/execution-status/${createResponse.json().instruction_id}`, + }); + + assert.equal(finalStatusResponse.statusCode, 200); + assert.equal(finalStatusResponse.json().status, 'FINAL'); + assert.equal(finalStatusResponse.json().finality_status, 'FINAL'); + assert.equal(finalStatusResponse.json().confirmation_depth, 3); + assert.equal(finalStatusResponse.json().required_confirmation_depth, 3); + assert.equal(finalStatusResponse.json().status_history.length, 4); + + const finalityReceiptResponse = await app.inject({ + method: 'GET', + url: `/finality-receipt/${createResponse.json().instruction_id}`, + }); + + assert.equal(finalityReceiptResponse.statusCode, 200); + assert.equal(finalityReceiptResponse.json().instruction_status, 'FINAL'); + assert.equal(finalityReceiptResponse.json().transaction_hash, transactionHash); + assert.equal(finalityReceiptResponse.json().block_number, 100); + assert.equal(finalityReceiptResponse.json().finality_status, 'FINAL'); + assert.equal(finalityReceiptResponse.json().transfer_verification.verified, true); + assert.equal( + finalityReceiptResponse.json().transfer_verification.token_contract_address, + tokenAddress, + ); + assert.equal(finalityReceiptResponse.json().transfer_verification.amount, '10.0'); + assert.ok(finalityReceiptResponse.json().included_at); + assert.ok(finalityReceiptResponse.json().final_at); + + const reportingNotificationsResponse = await app.inject({ + method: 'GET', + url: `/reporting/notifications?instruction_id=${createResponse.json().instruction_id}`, + }); + + assert.equal(reportingNotificationsResponse.statusCode, 200); + assert.equal(reportingNotificationsResponse.json().total_matched, 2); + const debitNotification = reportingNotificationsResponse.json().notifications.find( + (notification) => notification.account_role === 'DEBTOR', + ); + assert.ok(debitNotification); + + const debitSpecNotificationResponse = await app.inject({ + method: 'GET', + url: `/report/notification/${debitNotification.notification_id}`, + }); + + assert.equal(debitSpecNotificationResponse.statusCode, 200); + assert.equal(debitSpecNotificationResponse.json().notification_type, 'ENTRY_FINAL'); + assert.equal(debitSpecNotificationResponse.json().entry.entry_status, 'BOOK'); + assert.equal( + debitSpecNotificationResponse.json().entry.blockchain_detail.finality_status, + 'FINAL', + ); + + const reportSearchResponse = await app.inject({ + method: 'GET', + url: `/report/search?wallet_address=${recipientAddress}&chain_dli=X9J9XDMTD&instruction_id=${createResponse.json().instruction_id}&finality_status=FINAL`, + }); + + assert.equal(reportSearchResponse.statusCode, 200); + assertReportEntrySearchResponseShape(reportSearchResponse.json()); + assert.equal(reportSearchResponse.json().total_matched, 1); + assert.equal( + reportSearchResponse.json().entries[0].transaction_hash, + transactionHash, + ); + assert.equal(reportSearchResponse.json().entries[0].finality_status, 'FINAL'); + assert.equal( + reportSearchResponse.json().entries[0].instruction_id, + createResponse.json().instruction_id, + ); + + await app.close(); +}); + +test('investigation cases can be created, updated, listed, and emitted through outbox/webhooks', async () => { + const app = await buildApp(); + + const createInstructionResponse = await app.inject({ + method: 'POST', + url: '/instruction', + payload: buildInstructionPayload({ + payment_identification: { + end_to_end_identification: 'INV-EXC-INVEST-001', + }, + interbank_settlement_amount: { + amount: '250000.00', + currency: 'USD', + }, + }), + }); + + assert.equal(createInstructionResponse.statusCode, 201); + const instruction = createInstructionResponse.json(); + const agedTimestamp = new Date(Date.now() - 12000).toISOString(); + const currentRecord = app.store.getInstruction(instruction.instruction_id); + app.store.saveInstruction({ + ...currentRecord, + created_at: agedTimestamp, + updated_at: agedTimestamp, + }); + const finalizedInstruction = await waitFor(() => { + const record = app.store.getInstruction(instruction.instruction_id); + assert.equal(record.status, 'FINAL'); + return record; + }); + assert.equal(finalizedInstruction.status, 'FINAL'); + + const notificationsResponse = await app.inject({ + method: 'GET', + url: `/reporting/notifications?instruction_id=${instruction.instruction_id}`, + }); + assert.equal(notificationsResponse.statusCode, 200); + const affectedNotificationId = + notificationsResponse.json().notifications[0].notification_id; + + const statementsResponse = await app.inject({ + method: 'GET', + url: `/reporting/statements?instruction_id=${instruction.instruction_id}`, + }); + assert.equal(statementsResponse.statusCode, 200); + const affectedStatementId = statementsResponse.json().statements[0].statement_id; + + const subscriptionResponse = await app.inject({ + method: 'POST', + url: '/webhook-endpoints', + payload: { + url: 'https://receiver.example/investigations', + signing_secret: 'whsec_investigations_123456', + subscribed_event_types: ['investigation_case.updated'], + }, + }); + + assert.equal(subscriptionResponse.statusCode, 201); + + const createCaseResponse = await app.inject({ + method: 'POST', + url: '/exceptions/investigations', + payload: buildInvestigationCasePayload({ + related_instruction_id: instruction.instruction_id, + assigned_team: 'CHAIN_OPERATIONS', + next_action_due_at: new Date(Date.now() + 60_000).toISOString(), + affected_notification_ids: [affectedNotificationId], + affected_statement_ids: [affectedStatementId], + }), + }); + + assert.equal(createCaseResponse.statusCode, 201); + const investigation = createCaseResponse.json(); + assertInvestigationCaseShape(investigation); + assert.equal(investigation.case_status, 'OPEN'); + assert.equal(investigation.related_instruction_id, instruction.instruction_id); + assert.equal(investigation.assigned_team, 'CHAIN_OPERATIONS'); + assert.equal(investigation.affected_notification_ids.length, 1); + assert.equal(investigation.affected_statement_ids.length, 1); + assert.equal( + investigation.traceability.resource_paths.investigation_case, + `/exceptions/investigations/${investigation.investigation_case_id}`, + ); + + const updateCaseResponse = await app.inject({ + method: 'PATCH', + url: `/exceptions/investigations/${investigation.investigation_case_id}`, + payload: { + case_status: 'WAITING_COUNTERPARTY', + current_owner: { + name: 'Counterparty Liaison Desk', + }, + counterparty_reference: 'CP-INV-2026-001', + reporting_follow_up_required: true, + resolution_summary: 'Counterparty confirmation requested.', + }, + }); + + assert.equal(updateCaseResponse.statusCode, 200); + assert.equal(updateCaseResponse.json().case_status, 'WAITING_COUNTERPARTY'); + assert.equal(updateCaseResponse.json().status_history.length, 2); + assert.equal(updateCaseResponse.json().current_owner.name, 'Counterparty Liaison Desk'); + assert.equal(updateCaseResponse.json().reporting_follow_up_required, true); + + const listResponse = await app.inject({ + method: 'GET', + url: `/exceptions/investigations?related_instruction_id=${instruction.instruction_id}&case_status=WAITING_COUNTERPARTY`, + }); + + assert.equal(listResponse.statusCode, 200); + assert.equal(listResponse.json().total_matched, 1); + assert.equal( + listResponse.json().investigation_cases[0].investigation_case_id, + investigation.investigation_case_id, + ); + + const instructionStillFinalResponse = await app.inject({ + method: 'GET', + url: `/instruction/${instruction.instruction_id}`, + }); + + assert.equal(instructionStillFinalResponse.statusCode, 200); + assert.equal(instructionStillFinalResponse.json().status, 'FINAL'); + + const outboxResponse = await app.inject({ + method: 'GET', + url: `/event-outbox?instruction_id=${instruction.instruction_id}&event_type=investigation_case.updated`, + }); + + assert.equal(outboxResponse.statusCode, 200); + assert.equal(outboxResponse.json().total_matched, 2); + + const deliveryResponse = await app.inject({ + method: 'GET', + url: `/webhook-endpoints/${subscriptionResponse.json().subscription_id}/deliveries?event_type=investigation_case.updated`, + }); + + assert.equal(deliveryResponse.statusCode, 200); + assert.equal(deliveryResponse.json().total_matched, 2); + + await app.close(); +}); + +test('closed investigation cases reject reopening transitions', async () => { + const app = await buildApp(); + + const createInstructionResponse = await app.inject({ + method: 'POST', + url: '/instruction', + payload: buildInstructionPayload({ + payment_identification: { + end_to_end_identification: 'INV-EXC-INV-CLOSE-001', + }, + }), + }); + + assert.equal(createInstructionResponse.statusCode, 201); + const instruction = createInstructionResponse.json(); + const agedTimestamp = new Date(Date.now() - 12000).toISOString(); + const currentRecord = app.store.getInstruction(instruction.instruction_id); + app.store.saveInstruction({ + ...currentRecord, + created_at: agedTimestamp, + updated_at: agedTimestamp, + }); + await waitFor(() => { + const record = app.store.getInstruction(instruction.instruction_id); + assert.equal(record.status, 'FINAL'); + return record; + }); + + const createCaseResponse = await app.inject({ + method: 'POST', + url: '/exceptions/investigations', + payload: buildInvestigationCasePayload({ + related_instruction_id: instruction.instruction_id, + }), + }); + + assert.equal(createCaseResponse.statusCode, 201); + const investigation = createCaseResponse.json(); + + const resolveResponse = await app.inject({ + method: 'PATCH', + url: `/exceptions/investigations/${investigation.investigation_case_id}`, + payload: { + case_status: 'RESOLVED', + resolution_type: 'INFORMATION_PROVIDED', + resolution_summary: 'Counterparty confirmed beneficiary credit.', + }, + }); + + assert.equal(resolveResponse.statusCode, 200); + + const closeResponse = await app.inject({ + method: 'PATCH', + url: `/exceptions/investigations/${investigation.investigation_case_id}`, + payload: { + case_status: 'CLOSED', + resolution_type: 'INFORMATION_PROVIDED', + resolution_summary: 'Case closed after confirmation.', + }, + }); + + assert.equal(closeResponse.statusCode, 200); + + const reopenResponse = await app.inject({ + method: 'PATCH', + url: `/exceptions/investigations/${investigation.investigation_case_id}`, + payload: { + case_status: 'IN_PROGRESS', + }, + }); + + assert.equal(reopenResponse.statusCode, 409); + assert.equal(reopenResponse.json().error, 'invalid_state'); + + await app.close(); +}); + +test('return cases can be created and settled without rewriting the original instruction', async () => { + const app = await buildApp(); + + const createInstructionResponse = await app.inject({ + method: 'POST', + url: '/instruction', + payload: buildInstructionPayload({ + payment_identification: { + end_to_end_identification: 'INV-EXC-RETURN-001', + }, + interbank_settlement_amount: { + amount: '250000.00', + currency: 'USD', + }, + }), + }); + + assert.equal(createInstructionResponse.statusCode, 201); + const instruction = createInstructionResponse.json(); + const agedTimestamp = new Date(Date.now() - 12000).toISOString(); + const currentRecord = app.store.getInstruction(instruction.instruction_id); + app.store.saveInstruction({ + ...currentRecord, + created_at: agedTimestamp, + updated_at: agedTimestamp, + }); + const finalizedInstruction = await waitFor(() => { + const record = app.store.getInstruction(instruction.instruction_id); + assert.equal(record.status, 'FINAL'); + return record; + }); + assert.equal(finalizedInstruction.status, 'FINAL'); + + const investigationResponse = await app.inject({ + method: 'POST', + url: '/exceptions/investigations', + payload: buildInvestigationCasePayload({ + related_instruction_id: instruction.instruction_id, + case_type: 'RETURN_REQUEST', + reason_code: 'RETURN_REQUESTED', + narrative: 'Return requested after beneficiary-side remediation flow.', + }), + }); + + assert.equal(investigationResponse.statusCode, 201); + const investigation = investigationResponse.json(); + + const createReturnResponse = await app.inject({ + method: 'POST', + url: '/exceptions/returns', + payload: buildReturnCasePayload({ + original_instruction_id: instruction.instruction_id, + linked_investigation_case_id: investigation.investigation_case_id, + }), + }); + + assert.equal(createReturnResponse.statusCode, 201); + const returnCase = createReturnResponse.json(); + assertReturnCaseShape(returnCase); + assert.equal(returnCase.return_status, 'PROPOSED'); + assert.equal(returnCase.original_instruction_id, instruction.instruction_id); + assert.equal( + returnCase.linked_investigation_case_id, + investigation.investigation_case_id, + ); + + const updateReturnResponse = await app.inject({ + method: 'PATCH', + url: `/exceptions/returns/${returnCase.return_case_id}`, + payload: { + return_status: 'SETTLED', + off_chain_reference: 'REFUND-2026-0001', + resolution_summary: 'Off-chain refund completed.', + }, + }); + + assert.equal(updateReturnResponse.statusCode, 200); + assert.equal(updateReturnResponse.json().return_status, 'SETTLED'); + assert.equal(updateReturnResponse.json().off_chain_reference, 'REFUND-2026-0001'); + assertIsoDateTime(updateReturnResponse.json().settled_at); + + const listReturnResponse = await app.inject({ + method: 'GET', + url: `/exceptions/returns?original_instruction_id=${instruction.instruction_id}&return_status=SETTLED`, + }); + + assert.equal(listReturnResponse.statusCode, 200); + assert.equal(listReturnResponse.json().total_matched, 1); + assert.equal( + listReturnResponse.json().return_cases[0].return_case_id, + returnCase.return_case_id, + ); + + const instructionStillFinalResponse = await app.inject({ + method: 'GET', + url: `/instruction/${instruction.instruction_id}`, + }); + + assert.equal(instructionStillFinalResponse.statusCode, 200); + assert.equal(instructionStillFinalResponse.json().status, 'FINAL'); + + const outboxResponse = await app.inject({ + method: 'GET', + url: `/event-outbox?instruction_id=${instruction.instruction_id}&event_type=return_case.updated`, + }); + + assert.equal(outboxResponse.statusCode, 200); + assert.equal(outboxResponse.json().total_matched, 2); + + await app.close(); +}); + +test('on-chain compensating return cases require compensating instruction before settlement', async () => { + const app = await buildApp(); + + const createInstructionResponse = await app.inject({ + method: 'POST', + url: '/instruction', + payload: buildInstructionPayload({ + payment_identification: { + end_to_end_identification: 'INV-EXC-RETURN-CHAIN-001', + }, + }), + }); + + assert.equal(createInstructionResponse.statusCode, 201); + const instruction = createInstructionResponse.json(); + const agedTimestamp = new Date(Date.now() - 12000).toISOString(); + const currentRecord = app.store.getInstruction(instruction.instruction_id); + app.store.saveInstruction({ + ...currentRecord, + created_at: agedTimestamp, + updated_at: agedTimestamp, + }); + await waitFor(() => { + const record = app.store.getInstruction(instruction.instruction_id); + assert.equal(record.status, 'FINAL'); + return record; + }); + + const createReturnResponse = await app.inject({ + method: 'POST', + url: '/exceptions/returns', + payload: buildReturnCasePayload({ + original_instruction_id: instruction.instruction_id, + return_method: 'ON_CHAIN_COMPENSATING_TRANSFER', + }), + }); + + assert.equal(createReturnResponse.statusCode, 201); + + const settleResponse = await app.inject({ + method: 'PATCH', + url: `/exceptions/returns/${createReturnResponse.json().return_case_id}`, + payload: { + return_status: 'SETTLED', + resolution_summary: 'Compensating transfer broadcast without stored reference.', + }, + }); + + assert.equal(settleResponse.statusCode, 400); + assert.equal(settleResponse.json().error, 'invalid_request'); + + await app.close(); +}); + +test('return cases reject instructions that have not reached final settlement', async () => { + const app = await buildApp(); + + const createInstructionResponse = await app.inject({ + method: 'POST', + url: '/instruction', + payload: buildInstructionPayload({ + payment_identification: { + end_to_end_identification: 'INV-EXC-RETURN-REJECT-001', + }, + }), + }); + + assert.equal(createInstructionResponse.statusCode, 201); + + const createReturnResponse = await app.inject({ + method: 'POST', + url: '/exceptions/returns', + payload: buildReturnCasePayload({ + original_instruction_id: createInstructionResponse.json().instruction_id, + }), + }); + + assert.equal(createReturnResponse.statusCode, 400); + assert.equal(createReturnResponse.json().error, 'invalid_state'); + + await app.close(); +}); + +async function createFinalInstruction(app, endToEndId) { + const createResponse = await app.inject({ + method: 'POST', + url: '/instruction', + payload: buildInstructionPayload({ + payment_identification: { + end_to_end_identification: endToEndId, + }, + }), + }); + assert.equal(createResponse.statusCode, 201); + const instruction = createResponse.json(); + const agedTimestamp = new Date(Date.now() - 12000).toISOString(); + const currentRecord = app.store.getInstruction(instruction.instruction_id); + app.store.saveInstruction({ + ...currentRecord, + created_at: agedTimestamp, + updated_at: agedTimestamp, + }); + await waitFor(() => { + const record = app.store.getInstruction(instruction.instruction_id); + assert.equal(record.status, 'FINAL'); + return record; + }); + return instruction; +} + +test('Tom v1.2 /return materializes a real compensating instruction and Tom-origin return case', async () => { + const app = await buildApp(); + const instruction = await createFinalInstruction(app, 'INV-TOM-RETURN-001'); + + const returnResponse = await app.inject({ + method: 'POST', + url: `/instruction/${instruction.instruction_id}/return`, + payload: { + return_identification: 'RET-TOM-001', + return_reason: { code: 'AC04' }, + returned_amount: { amount: '250000.00', currency: 'USD' }, + }, + }); + + assert.equal(returnResponse.statusCode, 202); + const body = returnResponse.json(); + assert.deepEqual(Object.keys(body).sort(), [ + 'accepted_at', + 'compensating_instruction_id', + 'compensating_uetr', + 'original_instruction_id', + 'return_identification', + 'status', + ]); + assertUuid(body.compensating_instruction_id); + assertUuid(body.compensating_uetr); + assert.equal(body.original_instruction_id, instruction.instruction_id); + assert.equal(body.return_identification, 'RET-TOM-001'); + assert.equal(body.status, 'PENDING'); + assertIsoDateTime(body.accepted_at); + + const compensating = await app.inject({ + method: 'GET', + url: `/instruction/${body.compensating_instruction_id}`, + }); + assert.equal(compensating.statusCode, 200); + assert.equal( + compensating.json().instruction_id, + body.compensating_instruction_id, + ); + assert.equal( + compensating.json().compensates_instruction_id, + instruction.instruction_id, + ); + + const list = await app.inject({ + method: 'GET', + url: `/exceptions/returns?original_instruction_id=${instruction.instruction_id}`, + }); + assert.equal(list.statusCode, 200); + assert.equal(list.json().total_matched, 1); + assert.equal(list.json().return_cases[0].return_status, 'APPROVED'); + assert.equal( + list.json().return_cases[0].compensating_instruction_id, + body.compensating_instruction_id, + ); + const returnCaseId = list.json().return_cases[0].return_case_id; + + const detail = await app.inject({ + method: 'GET', + url: `/exceptions/returns/${returnCaseId}`, + }); + assert.equal(detail.statusCode, 200); + assert.equal(detail.json().exception_type, 'RETURN'); + assert.equal(detail.json().origin, 'TOM'); + + await app.close(); +}); + +test('Tom v1.2 /reverse records REQUESTED case and reversal-status returns it without compensating fields', async () => { + const app = await buildApp(); + const instruction = await createFinalInstruction(app, 'INV-TOM-REVERSE-001'); + + const reverseResponse = await app.inject({ + method: 'POST', + url: `/instruction/${instruction.instruction_id}/reverse`, + payload: { + reversal_reason: { code: 'DUPL' }, + reversed_amount: { amount: '250000.00', currency: 'USD' }, + webhook_url: 'https://example.com/reversal-webhook', + }, + }); + + assert.equal(reverseResponse.statusCode, 202); + const body = reverseResponse.json(); + assert.deepEqual(Object.keys(body).sort(), [ + 'original_instruction_id', + 'receiver_response_expected_by', + 'requested_at', + 'reversal_identification', + 'reversal_request_id', + 'status', + ]); + assertUuid(body.reversal_request_id); + assert.equal(body.original_instruction_id, instruction.instruction_id); + assert.equal(body.status, 'REQUESTED'); + assert.ok(typeof body.reversal_identification === 'string' && body.reversal_identification.length > 0); + assertIsoDateTime(body.requested_at); + assertIsoDateTime(body.receiver_response_expected_by); + assert.equal(body.compensating_instruction_id, undefined); + + const statusResponse = await app.inject({ + method: 'GET', + url: `/instruction/${instruction.instruction_id}/reversal-status`, + }); + assert.equal(statusResponse.statusCode, 200); + const status = statusResponse.json(); + assert.deepEqual(Object.keys(status).sort(), [ + 'original_instruction_id', + 'requested_at', + 'reversal_request_id', + 'status', + ]); + assert.equal(status.status, 'REQUESTED'); + assert.equal(status.original_instruction_id, instruction.instruction_id); + assert.equal(status.compensating_instruction_id, undefined); + assert.equal(status.compensating_instruction_status, undefined); + + await app.close(); +}); + +test('Tom v1.2 reversal cases are excluded from /exceptions/returns list and detail', async () => { + const app = await buildApp(); + const instruction = await createFinalInstruction(app, 'INV-TOM-REVERSE-EXCL-001'); + + const reverseResponse = await app.inject({ + method: 'POST', + url: `/instruction/${instruction.instruction_id}/reverse`, + payload: { + reversal_reason: { code: 'CUST' }, + reversed_amount: { amount: '250000.00', currency: 'USD' }, + }, + }); + assert.equal(reverseResponse.statusCode, 202); + const reversalRequestId = reverseResponse.json().reversal_request_id; + + const listResponse = await app.inject({ + method: 'GET', + url: `/exceptions/returns?original_instruction_id=${instruction.instruction_id}`, + }); + assert.equal(listResponse.statusCode, 200); + assert.equal(listResponse.json().total_matched, 0); + + const detailResponse = await app.inject({ + method: 'GET', + url: `/exceptions/returns/${reversalRequestId}`, + }); + assert.equal(detailResponse.statusCode, 404); + + await app.close(); +}); + +test('Tom v1.2 routes return 404 INSTRUCTION_NOT_FOUND for unknown instruction', async () => { + const app = await buildApp(); + const missingId = '00000000-0000-4000-8000-000000000000'; + + const returnResponse = await app.inject({ + method: 'POST', + url: `/instruction/${missingId}/return`, + payload: { + return_reason: { code: 'AC04' }, + returned_amount: { amount: '1.00', currency: 'USD' }, + }, + }); + assert.equal(returnResponse.statusCode, 404); + assert.equal(returnResponse.json().code, 'INSTRUCTION_NOT_FOUND'); + + const reverseResponse = await app.inject({ + method: 'POST', + url: `/instruction/${missingId}/reverse`, + payload: { + reversal_reason: { code: 'DUPL' }, + reversed_amount: { amount: '1.00', currency: 'USD' }, + }, + }); + assert.equal(reverseResponse.statusCode, 404); + assert.equal(reverseResponse.json().code, 'INSTRUCTION_NOT_FOUND'); + + const reversalStatusResponse = await app.inject({ + method: 'GET', + url: `/instruction/${missingId}/reversal-status`, + }); + assert.equal(reversalStatusResponse.statusCode, 404); + assert.equal(reversalStatusResponse.json().code, 'INSTRUCTION_NOT_FOUND'); + + await app.close(); +}); + +test('Tom v1.2 routes reject invalid path UUID and malformed body', async () => { + const app = await buildApp(); + + const invalidPath = await app.inject({ + method: 'POST', + url: '/instruction/not-a-uuid/return', + payload: { + return_reason: { code: 'AC04' }, + returned_amount: { amount: '1.00', currency: 'USD' }, + }, + }); + assert.equal(invalidPath.statusCode, 400); + assert.equal(invalidPath.json().code, 'INVALID_REQUEST'); + + const reversalInvalidPath = await app.inject({ + method: 'GET', + url: '/instruction/not-a-uuid/reversal-status', + }); + assert.equal(reversalInvalidPath.statusCode, 400); + assert.equal(reversalInvalidPath.json().code, 'INVALID_REQUEST'); + + const instruction = await createFinalInstruction(app, 'INV-TOM-VAL-001'); + + const malformedAmount = await app.inject({ + method: 'POST', + url: `/instruction/${instruction.instruction_id}/return`, + payload: { + return_reason: { code: 'AC04' }, + returned_amount: { + amount: 'not-a-number', + currency: 'USD', + }, + }, + }); + assert.equal(malformedAmount.statusCode, 400); + assert.equal(malformedAmount.json().code, 'INVALID_REQUEST'); + + const longCurrency = await app.inject({ + method: 'POST', + url: `/instruction/${instruction.instruction_id}/return`, + payload: { + return_reason: { code: 'AC04' }, + returned_amount: { + amount: '1.00', + currency: 'X'.repeat(21), + }, + }, + }); + assert.equal(longCurrency.statusCode, 400); + + const returnWebhook = await app.inject({ + method: 'POST', + url: `/instruction/${instruction.instruction_id}/return`, + payload: { + return_reason: { code: 'AC04' }, + returned_amount: { amount: '1.00', currency: 'USD' }, + webhook_url: 'https://example.com/not-allowed-on-return', + }, + }); + assert.equal(returnWebhook.statusCode, 400); + assert.equal(returnWebhook.json().code, 'INVALID_REQUEST'); + assert.ok( + returnWebhook.json().details.some((detail) => detail.field === 'webhook_url'), + ); + + const narrMissingInfo = await app.inject({ + method: 'POST', + url: `/instruction/${instruction.instruction_id}/return`, + payload: { + return_reason: { code: 'NARR' }, + returned_amount: { amount: '1.00', currency: 'USD' }, + }, + }); + assert.equal(narrMissingInfo.statusCode, 400); + + const narrTooMany = await app.inject({ + method: 'POST', + url: `/instruction/${instruction.instruction_id}/return`, + payload: { + return_reason: { + code: 'NARR', + additional_information: ['a', 'b', 'c', 'd', 'e', 'f'], + }, + returned_amount: { amount: '1.00', currency: 'USD' }, + }, + }); + assert.equal(narrTooMany.statusCode, 400); + + const narrTooLong = await app.inject({ + method: 'POST', + url: `/instruction/${instruction.instruction_id}/reverse`, + payload: { + reversal_reason: { + code: 'NARR', + additional_information: ['x'.repeat(106)], + }, + reversed_amount: { amount: '1.00', currency: 'USD' }, + }, + }); + assert.equal(narrTooLong.statusCode, 400); + + await app.close(); +}); + +test('Tom v1.2 /return rejects non-FINAL original instructions', async () => { + const app = await buildApp(); + + const createInstructionResponse = await app.inject({ + method: 'POST', + url: '/instruction', + payload: buildInstructionPayload({ + payment_identification: { + end_to_end_identification: 'INV-TOM-NOT-FINAL-001', + }, + }), + }); + assert.equal(createInstructionResponse.statusCode, 201); + + const returnResponse = await app.inject({ + method: 'POST', + url: `/instruction/${createInstructionResponse.json().instruction_id}/return`, + payload: { + return_reason: { code: 'AC04' }, + returned_amount: { amount: '1.00', currency: 'USD' }, + }, + }); + assert.equal(returnResponse.statusCode, 409); + assert.equal(returnResponse.json().code, 'INSTRUCTION_NOT_FINAL'); + + await app.close(); +}); + +test('Tom v1.2 /return rejects duplicate active Tom-origin RETURN with ALREADY_RETURNED', async () => { + const app = await buildApp(); + const instruction = await createFinalInstruction(app, 'INV-TOM-DUP-RETURN-001'); + + const first = await app.inject({ + method: 'POST', + url: `/instruction/${instruction.instruction_id}/return`, + payload: { + return_reason: { code: 'AC04' }, + returned_amount: { amount: '1.00', currency: 'USD' }, + }, + }); + assert.equal(first.statusCode, 202); + + const second = await app.inject({ + method: 'POST', + url: `/instruction/${instruction.instruction_id}/return`, + payload: { + return_reason: { code: 'AC04' }, + returned_amount: { amount: '1.00', currency: 'USD' }, + }, + }); + assert.equal(second.statusCode, 409); + assert.equal(second.json().code, 'ALREADY_RETURNED'); + + const reverseAttempt = await app.inject({ + method: 'POST', + url: `/instruction/${instruction.instruction_id}/reverse`, + payload: { + reversal_reason: { code: 'DUPL' }, + reversed_amount: { amount: '1.00', currency: 'USD' }, + }, + }); + assert.equal(reverseAttempt.statusCode, 409); + assert.equal(reverseAttempt.json().code, 'ALREADY_RETURNED'); + + await app.close(); +}); + +test('Tom v1.2 /reverse rejects duplicate active reversal with ALREADY_RETURNED', async () => { + const app = await buildApp(); + const instruction = await createFinalInstruction(app, 'INV-TOM-DUP-REVERSAL-001'); + + const first = await app.inject({ + method: 'POST', + url: `/instruction/${instruction.instruction_id}/reverse`, + payload: { + reversal_reason: { code: 'DUPL' }, + reversed_amount: { amount: '1.00', currency: 'USD' }, + }, + }); + assert.equal(first.statusCode, 202); + + const second = await app.inject({ + method: 'POST', + url: `/instruction/${instruction.instruction_id}/reverse`, + payload: { + reversal_reason: { code: 'DUPL' }, + reversed_amount: { amount: '1.00', currency: 'USD' }, + }, + }); + assert.equal(second.statusCode, 409); + assert.equal(second.json().code, 'ALREADY_RETURNED'); + + await app.close(); +}); diff --git a/scripts/create-visual-flow-video.mjs b/scripts/create-visual-flow-video.mjs new file mode 100644 index 0000000..080530f --- /dev/null +++ b/scripts/create-visual-flow-video.mjs @@ -0,0 +1,394 @@ +#!/usr/bin/env node +import { mkdir, writeFile, copyFile, access } from 'node:fs/promises'; +import { constants } from 'node:fs'; +import { dirname, resolve, relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(__dirname, '..'); +const args = process.argv.slice(2); + +function argValue(name) { + const idx = args.indexOf(name); + if (idx === -1) return null; + return args[idx + 1] || null; +} + +const musicArg = argValue('--music'); +const withNarration = args.includes('--narration'); +const voice = argValue('--voice') || 'af_heart'; +const duration = Number(argValue('--duration') || (withNarration ? 52 : 38)); +const width = Number(argValue('--width') || 1920); +const height = Number(argValue('--height') || 1080); +const outDirName = withNarration ? 'visual-flow-map-narrated' : 'visual-flow-map'; +const outDir = resolve(repoRoot, 'videos', outDirName); + +async function exists(path) { + try { + await access(path, constants.F_OK); + return true; + } catch { + return false; + } +} + +function run(cmd, cmdArgs, opts = {}) { + const result = spawnSync(cmd, cmdArgs, { + cwd: opts.cwd || repoRoot, + stdio: opts.stdio || 'inherit', + encoding: 'utf8', + }); + if (result.status !== 0) { + throw new Error(`${cmd} ${cmdArgs.join(' ')} failed with status ${result.status}`); + } + return result; +} + +async function prepareNarration() { + if (!withNarration) return null; + const script = `pacs.crypto is a visual reference for a narrow bank to VASP payment flow. +It starts with Travel Rule identity and remittance data, then a quote, then an executable instruction linked to that record. +The chain adapter moves the instruction from pending, to broadcast, to confirming, to final. +From there, the reference stack exposes status, finality, reporting, and an evidence pack for reviewers. +For returns and reversals, the visual keeps pacs dot zero zero four and pacs dot zero zero seven terminology, because those names are meaningful to payment practitioners. +It keeps return and reversal tied to the instruction lifecycle, not a separate normative API expansion. +Cancellation stays limited to the pre-broadcast camt dot zero fifty six window. +The richer investigation family remains useful draft machinery, not a locked standard shape. +The PR stays draft; the question for Tom is whether this makes the spec discussion easier.`; + await writeFile(resolve(outDir, 'narration.txt'), script); + run('npx', ['hyperframes', 'tts', 'narration.txt', '--voice', voice, '--output', 'narration.wav'], { cwd: outDir }); + return 'narration.wav'; +} + +async function prepareMusic() { + const target = resolve(outDir, 'background-music.mp3'); + if (musicArg) { + const src = resolve(process.cwd(), musicArg); + await copyFile(src, target); + return 'background-music.mp3'; + } + + // Copyright-safe ambient bed: three quiet sine layers with fade-in/out. + // Replace with --music /path/to/track.mp3 when a human-selected track exists. + run('ffmpeg', [ + '-y', + '-f', 'lavfi', '-i', `sine=frequency=110:duration=${duration}:sample_rate=48000`, + '-f', 'lavfi', '-i', `sine=frequency=220:duration=${duration}:sample_rate=48000`, + '-f', 'lavfi', '-i', `sine=frequency=329.63:duration=${duration}:sample_rate=48000`, + '-filter_complex', + `[0:a]volume=0.11[a0];[1:a]volume=0.055[a1];[2:a]volume=0.035[a2];[a0][a1][a2]amix=inputs=3:duration=longest,afade=t=in:st=0:d=2,afade=t=out:st=${Math.max(duration - 3, 1)}:d=3`, + '-codec:a', 'libmp3lame', '-q:a', '5', + target, + ]); + return 'background-music.mp3'; +} + +function html(musicFile, narrationFile) { + const sceneDuration = duration / 6; + const starts = Array.from({ length: 6 }, (_, i) => +(i * sceneDuration).toFixed(3)); + const sceneDur = +Math.max(sceneDuration - 0.08, 0).toFixed(3); + return ` + + + + +pacs.crypto Visual Flow Map Video + + + +
+ + ${narrationFile ? `` : ''} + +
+
+
pacs.crypto visual reference
+

From ISO 20022 message semantics to executable blockchain payment flows.

+
+
+
Travel Rule
+
Instruction
+
Finality
+
pacs.004 / pacs.007
+
E&I draft
+
+
Draft PR visual reference: clarify lifecycle, do not expand normative scope.
+
+ +
+
+
Implemented wedge
+

One narrow corridor first: bank → sending VASP → chain → receiving VASP.

+
+
+ +
Travel Rulepacs.008 data
+
Quotefees + slippage
+
Instructionlinked record
+
ChainPENDING → FINAL
+
Finalitycamt.025-like
+
StatusGET /instruction
+
Reportingcamt.054/052/053
+
Evidencereviewer pack
+
Demovisual console
+
+
Narrow lifecycle: identity, quote, instruction, chain, finality.
+
+ +
+
+
Tom alignment
+

Keep recognised ISO names where they clarify the blockchain remediation path.

+
+
+
Return

pacs.004 remains the industry-readable return vocabulary.

+
Reverse

pacs.007 stays aligned with post-settlement remediation.

+
Cancel

camt.056 is only realistic before public-chain broadcast.

+
+
Return and reversal stay aligned with pacs.004 and pacs.007.
+
+ +
+
+
Investigation boundary
+

The exception-family layer stays useful, but not locked into the spec yet.

+
+
+ +
InstructionFINAL
+
Return requestPOST /instruction/{instructionId}/return
+
Reversal statusPOST /instruction/{instructionId}/reverseGET /instruction/{instructionId}/reversal-status
+
Investigation familycamt.026 / 027 / 087 TBD
+
draft input, not normative lock-in
+
+
Investigation and E&I flows stay draft until the API shape is agreed.
+
+ +
+
+
Reviewer package
+

The visual layer points reviewers to what is implemented, partial, and still draft.

+
+
+
Implemented

Reference server routes, simulators, happy-path evidence, status, finality, reporting.

+
Partial

Pre-broadcast cancellation window and testnet execution caveats.

+
Draft

Richer investigation and request-for-information family.

+
+
Green is implemented, amber is partial, rose remains draft.
+
+ +
+
+
Next review question
+

Is this visual format useful for spec and reference-implementation discussion?

+
+
+
Lifecycle map
+
Sequence diagram
+
Return/reversal split
+
Investigation TBD
+
+
Next pass can focus on E&I and liquidity management.
+
+
+ + + +`; +} + +async function main() { + await mkdir(outDir, { recursive: true }); + const musicFile = await prepareMusic(); + const narrationFile = await prepareNarration(); + await writeFile(resolve(outDir, 'index.html'), html(musicFile, narrationFile)); + await writeFile(resolve(outDir, 'DESIGN.md'), `# pacs.crypto visual flow video design\n\n## Style Prompt\nTechnical, dark, calm, reviewer-facing. The video should feel like a standards/reference-stack briefing, not a crypto hype reel.\n\n## Colors\n- Deep navy canvas: #07111f\n- Panel navy: #0d1b2e\n- Cyan accent: #36d6ff\n- Green implemented: #5cf2a9\n- Amber partial/remediation: #f7c45f\n- Rose draft/TBD: #ff6b91\n\n## Typography\n- Inter/system sans for headings and subtitles.\n- ui-monospace/SFMono for endpoint and ISO identifiers.\n\n## What NOT to Do\n- No speculative standards claims.\n- No normative API expansion from the visual artifact.\n- No token-price, moon, or trading visuals.\n- Narration is optional and generated locally when --narration is passed.\n- No fast glitch transitions that make ISO identifiers unreadable.\n`); + const renderOutput = withNarration + ? 'videos/visual-flow-map-narrated/renders/pacs-crypto-visual-flow-tom-preview-narrated.mp4' + : 'videos/visual-flow-map/renders/pacs-crypto-visual-flow-tom-preview.mp4'; + await writeFile(resolve(outDir, 'README.md'), `# pacs.crypto visual flow video\n\nGenerated by \`scripts/create-visual-flow-video.mjs\`.\n\n## Build\n\nFrom repo root:\n\n\`\`\`bash\nnode scripts/create-visual-flow-video.mjs${withNarration ? ' --narration' : ''}\nnpx hyperframes lint videos/${outDirName}\nnpx hyperframes validate videos/${outDirName}\nnpx hyperframes inspect videos/${outDirName}\nnpx hyperframes render videos/${outDirName} --quality draft --fps 24 --workers 1 --output ${renderOutput}\n\`\`\`\n\nUse your own background track:\n\n\`\`\`bash\nnode scripts/create-visual-flow-video.mjs${withNarration ? ' --narration' : ''} --music /path/to/background.mp3\n\`\`\`\n\n${withNarration ? 'Narration is stored in `narration.txt` and rendered to `narration.wav` by Hyperframes TTS.' : 'No narration is included. Captions are baked into the composition as scene subtitles.'}\n`); + + console.log(`Created HyperFrames project at ${relative(repoRoot, outDir)}`); + console.log(`Music: ${musicFile}${musicArg ? ' (copied from --music)' : ' (generated copyright-safe placeholder)'}`); + if (narrationFile) console.log(`Narration: ${narrationFile} (voice ${voice})`); + console.log('Next: npx hyperframes lint videos/visual-flow-map'); +} + +main().catch((err) => { + console.error(err && err.stack ? err.stack : err); + process.exit(1); +}); diff --git a/travel-rule-simulator-v3.html b/travel-rule-simulator-v3.html index 0461569..5fa0c78 100644 --- a/travel-rule-simulator-v3.html +++ b/travel-rule-simulator-v3.html @@ -129,6 +129,7 @@ .sbadge{font-family:var(--mono);font-size:12px;font-weight:700;padding:3px 10px;border-radius:4px} .s201,.s200{background:var(--green-bg);border:1px solid rgba(63,185,80,.35);color:var(--green)} .s404,.s400{background:rgba(248,81,73,.1);border:1px solid rgba(248,81,73,.3);color:var(--red)} + .s500{background:rgba(210,153,34,.1);border:1px solid rgba(210,153,34,.3);color:var(--amber)} .rt{font-family:var(--mono);font-size:10px;color:var(--dim);margin-left:auto} .rb{flex:1;overflow-y:auto;padding:16px;display:flex;flex-direction:column;gap:10px} @@ -581,6 +582,15 @@

Travel Rule API v3

+
Execution Mode
+
Mode
+
+
API Base URL used in LIVE mode
+
+ @@ -883,7 +893,7 @@

Travel Rule API v3

cname:'Marie Dupont',cctry:'BE',clei:'', cwallet:'3FZbgi29cpjq2GjdwV8eyHuJJnkLtktZc5',cwtype:'HOSTED', ovasp:'Unhosted Wallet (Self-Custody)','ovasp-lei':'',ocrypto:'','ocrypto-lei':'', - bvasp:'Kraken Belgium BVBA','bvasp-lei':'5299000DUFB71VFOHVB49',bcrypto:'','bcrypto-lei':'', + bvasp:'Kraken Belgium BVBA','bvasp-lei':'635400DUFB71VFOHVB49',bcrypto:'','bcrypto-lei':'', chain:'DLID/4H95J0R2X',txid:'b7e1f4a3d8c2b9f0e5a4c7d3b8f1e6a9c2d5b8e1f4a7c0d3b6f9e2a5c8d1b4', block:'887241',conf:'6',txstatus:'CONFIRMED', dstchain:'',dsttxid:'',bridge:'',bridgecontract:'',dstdti:'',dstamt:'', @@ -957,7 +967,7 @@

Travel Rule API v3

method:'CALLBACK', info:'Callback — UNDER_REVIEW
Kraken Belgium: unhosted wallet triggers EU TFR Art.17 enhanced due diligence.
Follow-up callback within 1 business day.', cb:{recordid:'7f3a2b1c-8d4e-5f6a-9b0c-1d2e3f4a5b6c',status:'UNDER_REVIEW', - vasp:'Kraken Belgium BVBA',lei:'5299000DUFB71VFOHVB49',ref:'KRK-BE-2025-11204', + vasp:'Kraken Belgium BVBA',lei:'635400DUFB71VFOHVB49',ref:'KRK-BE-2025-11204', field:'',code:'MISSING_MANDATORY_FIELD',desc:'', review:'Transfer involves an unhosted wallet (EU TFR Art.17). Enhanced due diligence review is in progress to verify ownership. Follow-up callback within 1 business day.'} }, @@ -1330,13 +1340,126 @@

Travel Rule API v3

}); } -function sendRequest() { +function getExecutionMode() { + return g('f-mode') || 'DEMO'; +} + +function getApiBase() { + return (g('f-api-base') || 'http://127.0.0.1:5050').replace(/\/+$/, ''); +} + +function buildLiveRequest(req) { + const base = getApiBase(); + if (req._type === 'GET') { + return { method:'GET', url:`${base}/travel-rule/${encodeURIComponent(req.record_id)}` }; + } + if (req._type === 'SEARCH') { + const { _type, ...params } = req; + const qs = new URLSearchParams(); + Object.entries(params).forEach(([k,v]) => { + if (v !== undefined && v !== '') qs.set(k, String(v)); + }); + return { method:'GET', url:`${base}/travel-rule/search${qs.toString() ? '?' + qs.toString() : ''}` }; + } + if (req._type === 'STATS') { + const { _type, ...params } = req; + const qs = new URLSearchParams(); + Object.entries(params).forEach(([k,v]) => { + if (v !== undefined && v !== '') qs.set(k, String(v)); + }); + return { method:'GET', url:`${base}/travel-rule/stats${qs.toString() ? '?' + qs.toString() : ''}` }; + } + if (req._type === 'CALLBACK') { + const { _type, _recordId, ...body } = req; + return { + method:'POST', + url:`${base}/travel-rule/${encodeURIComponent(_recordId)}/callback`, + body + }; + } + if (req._type === 'PUT') { + const { _type, ...body } = req; + const recordId = body.travel_rule_data?.payment_identification?.uetr || g('f-uetr'); + return { + method:'PUT', + url:`${base}/travel-rule/${encodeURIComponent(recordId)}`, + body + }; + } + + const { _type, ...body } = req; + return { + method:'POST', + url:`${base}/travel-rule`, + body + }; +} + +function renderLiveResponse(method, url, status, elapsed, payload, rawText) { + const statusClass = status >= 200 && status < 300 ? 's200' : status >= 400 && status < 500 ? 's400' : 's500'; + const pretty = payload !== undefined ? JSON.stringify(payload, null, 2) : rawText || ''; + document.getElementById('rh').innerHTML = + `${status} + ${method} ${url.replace(getApiBase(),'')} + ${elapsed}ms`; + document.getElementById('rbody').innerHTML = ` +
+
+
+ 🌐Live API Response +
+
+
execution_mode
LIVE API
+
api_base
${getApiBase()}
+
http_status
${status}
+
+
+
+
📦Response Payload
+
+
${highlight(pretty || 'null')}
+
+
+
`; +} + +async function sendLiveRequest(req) { + const started = performance.now(); + const live = buildLiveRequest(req); + const init = { + method: live.method, + headers: live.body ? { 'Content-Type':'application/json' } : undefined, + body: live.body ? JSON.stringify(live.body) : undefined + }; + + try { + const response = await fetch(live.url, init); + const text = await response.text(); + const payload = text ? JSON.parse(text) : null; + const elapsed = Math.round(performance.now() - started); + + if (payload?.record_id) s('f-uetr', payload.record_id); + renderLiveResponse(live.method, live.url, response.status, elapsed, payload, text); + } catch (error) { + const elapsed = Math.round(performance.now() - started); + renderLiveResponse(live.method, live.url, 0, elapsed, { + error: 'connection_failed', + message: error.message + }, ''); + } +} + +async function sendRequest() { updatePreview(); const req = buildReq(); document.getElementById('rh').innerHTML = `
Connecting…`; document.getElementById('rbody').innerHTML = ''; + if (getExecutionMode() === 'LIVE') { + await sendLiveRequest(req); + return; + } setTimeout(() => renderResponse(req), 600 + Math.random()*300); } @@ -1469,7 +1592,7 @@

Travel Rule API v3

status:'UNDER_REVIEW', submission_timing:'POST_TX', primary_chain_id:'DLID/4H95J0R2X', is_bridge:false, debtor_name:'Marie Dupont', debtor_vasp_lei:'', - creditor_name:'Marie Dupont', creditor_vasp_lei:'5299000DUFB71VFOHVB49', + creditor_name:'Marie Dupont', creditor_vasp_lei:'635400DUFB71VFOHVB49', settlement_amount:'0.03150000', settlement_currency:'XXX', token_identification:{ dti:'DTID/4H95J0R2X', ticker:'BTC' }, debtor_wallet_type:'UNHOSTED', creditor_wallet_type:'HOSTED', @@ -1595,7 +1718,7 @@

Travel Rule API v3

{ dimension_value:'7245007VX57GR4IUVZ79', dimension_label:'Bitvavo B.V.', record_count:89, rejection_count:3, rejection_rate_pct:3.37, volumes:[{ currency:'EUR', total_amount:'4200000.00', record_count:89 }] }, - { dimension_value:'5299000DUFB71VFOHVB49', dimension_label:'Kraken Belgium BVBA', + { dimension_value:'635400DUFB71VFOHVB49', dimension_label:'Kraken Belgium BVBA', record_count:76, rejection_count:11, rejection_rate_pct:14.47, volumes:[{ currency:'EUR', total_amount:'1850000.00', record_count:41 }, { currency:'XXX', token_identification:{ dti:'DTID/4H95J0R2X', ticker:'BTC' }, total_amount:'4.82350000', record_count:35 }] }, diff --git a/videos/visual-flow-map-narrated/DESIGN.md b/videos/visual-flow-map-narrated/DESIGN.md new file mode 100644 index 0000000..a8135d0 --- /dev/null +++ b/videos/visual-flow-map-narrated/DESIGN.md @@ -0,0 +1,22 @@ +# pacs.crypto visual flow video design + +## Style Prompt +Technical, dark, calm, reviewer-facing. The video should feel like a standards/reference-stack briefing, not a crypto hype reel. + +## Colors +- Deep navy canvas: #07111f +- Panel navy: #0d1b2e +- Cyan accent: #36d6ff +- Green implemented: #5cf2a9 +- Amber partial/remediation: #f7c45f +- Rose draft/TBD: #ff6b91 + +## Typography +- Inter/system sans for headings and subtitles. +- ui-monospace/SFMono for endpoint and ISO identifiers. + +## What NOT to Do +- No speculative standards claims. +- No token-price, moon, or trading visuals. +- Narration is optional and generated locally when --narration is passed. +- No fast glitch transitions that make ISO identifiers unreadable. diff --git a/videos/visual-flow-map-narrated/README.md b/videos/visual-flow-map-narrated/README.md new file mode 100644 index 0000000..eef469f --- /dev/null +++ b/videos/visual-flow-map-narrated/README.md @@ -0,0 +1,31 @@ +# pacs.crypto visual flow video — narrated cut + +Generated by `scripts/create-visual-flow-video.mjs --narration`. + +This cut is a reviewer aid for the draft PR. It should explain the current lifecycle and boundaries without expanding the normative API surface. + +## Scope posture + +- Keep the PR draft. +- Do not add normative API scope from the visual artifact. +- Focus the visual around instruction lifecycle, Travel Rule linkage, finality, `pacs.004`, `pacs.007`, `camt.056` pre-broadcast cancellation, and future E&I / investigation as draft only. + +## Build + +From repo root: + +```bash +node scripts/create-visual-flow-video.mjs --narration +npx hyperframes lint videos/visual-flow-map-narrated +npx hyperframes validate videos/visual-flow-map-narrated +npx hyperframes inspect videos/visual-flow-map-narrated +npx hyperframes render videos/visual-flow-map-narrated --quality draft --fps 24 --workers 1 --output videos/visual-flow-map-narrated/renders/pacs-crypto-visual-flow-tom-preview-narrated.mp4 +``` + +Use your own background track: + +```bash +node scripts/create-visual-flow-video.mjs --narration --music /path/to/background.mp3 +``` + +Narration is stored in `narration.txt` and rendered to `narration.wav` by Hyperframes TTS. diff --git a/videos/visual-flow-map-narrated/background-music.mp3 b/videos/visual-flow-map-narrated/background-music.mp3 new file mode 100644 index 0000000..b227a7b Binary files /dev/null and b/videos/visual-flow-map-narrated/background-music.mp3 differ diff --git a/videos/visual-flow-map-narrated/index.html b/videos/visual-flow-map-narrated/index.html new file mode 100644 index 0000000..4134b8f --- /dev/null +++ b/videos/visual-flow-map-narrated/index.html @@ -0,0 +1,281 @@ + + + + + +pacs.crypto Visual Flow Map Video + + + +
+ + + +
+
+
pacs.crypto visual reference
+

From ISO 20022 message semantics to executable blockchain payment flows.

+
+
+
Travel Rule
+
Instruction
+
Finality
+
pacs.004 / pacs.007
+
E&I draft
+
+
Draft PR visual reference: clarify lifecycle, do not expand normative scope.
+
+ +
+
+
Implemented wedge
+

One narrow corridor first: bank → sending VASP → chain → receiving VASP.

+
+
+ +
Travel Rulepacs.008 data
+
Quotefees + slippage
+
Instructionlinked record
+
ChainPENDING → FINAL
+
Finalitycamt.025-like
+
StatusGET /instruction
+
Reportingcamt.054/052/053
+
Evidencereviewer pack
+
Demovisual console
+
+
Narrow lifecycle: identity, quote, instruction, chain, finality.
+
+ +
+
+
Tom alignment
+

Keep recognised ISO names where they clarify the blockchain remediation path.

+
+
+
Return

pacs.004 remains the industry-readable return vocabulary.

+
Reverse

pacs.007 stays aligned with post-settlement remediation.

+
Cancel

camt.056 is only realistic before public-chain broadcast.

+
+
Return and reversal stay aligned with pacs.004 and pacs.007.
+
+ +
+
+
Investigation boundary
+

The exception-family layer stays useful, but not locked into the spec yet.

+
+
+ +
InstructionFINAL
+
Return requestPOST /instruction/{instructionId}/return
+
Reversal statusPOST /instruction/{instructionId}/reverseGET /instruction/{instructionId}/reversal-status
+
Investigation familycamt.026 / 027 / 087 TBD
+
draft input, not normative lock-in
+
+
Investigation and E&I flows stay draft until the API shape is agreed.
+
+ +
+
+
Reviewer package
+

The visual layer points reviewers to what is implemented, partial, and still draft.

+
+
+
Implemented

Reference server routes, simulators, happy-path evidence, status, finality, reporting.

+
Partial

Pre-broadcast cancellation window and testnet execution caveats.

+
Draft

Richer investigation and request-for-information family.

+
+
Green is implemented, amber is partial, rose remains draft.
+
+ +
+
+
Next review question
+

Is this visual format useful for spec and reference-implementation discussion?

+
+
+
Lifecycle map
+
Sequence diagram
+
Return/reversal split
+
Investigation TBD
+
+
Next pass can focus on E&I and liquidity management.
+
+
+ + + + \ No newline at end of file diff --git a/videos/visual-flow-map-narrated/narration.txt b/videos/visual-flow-map-narrated/narration.txt new file mode 100644 index 0000000..0d2b4e8 --- /dev/null +++ b/videos/visual-flow-map-narrated/narration.txt @@ -0,0 +1,9 @@ +pacs.crypto is a visual reference for a narrow bank to VASP payment flow. +It starts with Travel Rule identity and remittance data, then a quote, then an executable instruction linked to that record. +The chain adapter moves the instruction from pending, to broadcast, to confirming, to final. +From there, the reference stack exposes status, finality, reporting, and an evidence pack for reviewers. +For returns and reversals, the visual keeps pacs dot zero zero four and pacs dot zero zero seven terminology, because those names are meaningful to payment practitioners. +It keeps return and reversal tied to the instruction lifecycle, not a separate normative API expansion. +Cancellation stays limited to the pre-broadcast camt dot zero fifty six window. +The richer investigation family remains useful draft machinery, not a locked standard shape. +The PR stays draft; the question for Tom is whether this makes the spec discussion easier. \ No newline at end of file diff --git a/videos/visual-flow-map-narrated/narration.wav b/videos/visual-flow-map-narrated/narration.wav new file mode 100644 index 0000000..34d8aa6 Binary files /dev/null and b/videos/visual-flow-map-narrated/narration.wav differ diff --git a/videos/visual-flow-map-narrated/renders/pacs-crypto-visual-flow-tom-preview-narrated-lite.mp4 b/videos/visual-flow-map-narrated/renders/pacs-crypto-visual-flow-tom-preview-narrated-lite.mp4 new file mode 100644 index 0000000..092938d Binary files /dev/null and b/videos/visual-flow-map-narrated/renders/pacs-crypto-visual-flow-tom-preview-narrated-lite.mp4 differ diff --git a/videos/visual-flow-map-narrated/renders/pacs-crypto-visual-flow-tom-preview-narrated.mp4 b/videos/visual-flow-map-narrated/renders/pacs-crypto-visual-flow-tom-preview-narrated.mp4 new file mode 100644 index 0000000..f5f4cf3 Binary files /dev/null and b/videos/visual-flow-map-narrated/renders/pacs-crypto-visual-flow-tom-preview-narrated.mp4 differ diff --git a/videos/visual-flow-map-narrated/renders/preview-narrated-25s.png b/videos/visual-flow-map-narrated/renders/preview-narrated-25s.png new file mode 100644 index 0000000..40686bb Binary files /dev/null and b/videos/visual-flow-map-narrated/renders/preview-narrated-25s.png differ diff --git a/videos/visual-flow-map-narrated/renders/preview-narrated-33s.png b/videos/visual-flow-map-narrated/renders/preview-narrated-33s.png new file mode 100644 index 0000000..6ae937a Binary files /dev/null and b/videos/visual-flow-map-narrated/renders/preview-narrated-33s.png differ diff --git a/videos/visual-flow-map-narrated/renders/preview-tom-narrated-17s.png b/videos/visual-flow-map-narrated/renders/preview-tom-narrated-17s.png new file mode 100644 index 0000000..1a9b8e9 Binary files /dev/null and b/videos/visual-flow-map-narrated/renders/preview-tom-narrated-17s.png differ diff --git a/videos/visual-flow-map-narrated/renders/preview-tom-narrated-27s.png b/videos/visual-flow-map-narrated/renders/preview-tom-narrated-27s.png new file mode 100644 index 0000000..a5920a3 Binary files /dev/null and b/videos/visual-flow-map-narrated/renders/preview-tom-narrated-27s.png differ diff --git a/videos/visual-flow-map/DESIGN.md b/videos/visual-flow-map/DESIGN.md new file mode 100644 index 0000000..a8135d0 --- /dev/null +++ b/videos/visual-flow-map/DESIGN.md @@ -0,0 +1,22 @@ +# pacs.crypto visual flow video design + +## Style Prompt +Technical, dark, calm, reviewer-facing. The video should feel like a standards/reference-stack briefing, not a crypto hype reel. + +## Colors +- Deep navy canvas: #07111f +- Panel navy: #0d1b2e +- Cyan accent: #36d6ff +- Green implemented: #5cf2a9 +- Amber partial/remediation: #f7c45f +- Rose draft/TBD: #ff6b91 + +## Typography +- Inter/system sans for headings and subtitles. +- ui-monospace/SFMono for endpoint and ISO identifiers. + +## What NOT to Do +- No speculative standards claims. +- No token-price, moon, or trading visuals. +- Narration is optional and generated locally when --narration is passed. +- No fast glitch transitions that make ISO identifiers unreadable. diff --git a/videos/visual-flow-map/README.md b/videos/visual-flow-map/README.md new file mode 100644 index 0000000..c7908b6 --- /dev/null +++ b/videos/visual-flow-map/README.md @@ -0,0 +1,31 @@ +# pacs.crypto visual flow video — clean cut + +Generated by `scripts/create-visual-flow-video.mjs`. + +This clean cut is the primary reviewer video: captions are baked into the composition so it reads without audio. It is a draft-PR reference artifact, not normative spec content. + +## Scope posture + +- Keep the PR draft. +- Do not add normative API scope from the visual artifact. +- Focus the visual around instruction lifecycle, Travel Rule linkage, finality, `pacs.004`, `pacs.007`, `camt.056` pre-broadcast cancellation, and future E&I / investigation as draft only. + +## Build + +From repo root: + +```bash +node scripts/create-visual-flow-video.mjs +npx hyperframes lint videos/visual-flow-map +npx hyperframes validate videos/visual-flow-map +npx hyperframes inspect videos/visual-flow-map +npx hyperframes render videos/visual-flow-map --quality draft --fps 24 --workers 1 --output videos/visual-flow-map/renders/pacs-crypto-visual-flow-tom-preview.mp4 +``` + +Use your own background track: + +```bash +node scripts/create-visual-flow-video.mjs --music /path/to/background.mp3 +``` + +No narration is included. Captions are baked into the composition as scene subtitles. diff --git a/videos/visual-flow-map/background-music.mp3 b/videos/visual-flow-map/background-music.mp3 new file mode 100644 index 0000000..4dc4ca7 Binary files /dev/null and b/videos/visual-flow-map/background-music.mp3 differ diff --git a/videos/visual-flow-map/index.html b/videos/visual-flow-map/index.html new file mode 100644 index 0000000..c964ec5 --- /dev/null +++ b/videos/visual-flow-map/index.html @@ -0,0 +1,281 @@ + + + + + +pacs.crypto Visual Flow Map Video + + + +
+ + + +
+
+
pacs.crypto visual reference
+

From ISO 20022 message semantics to executable blockchain payment flows.

+
+
+
Travel Rule
+
Instruction
+
Finality
+
pacs.004 / pacs.007
+
E&I draft
+
+
Draft PR visual reference: clarify lifecycle, do not expand normative scope.
+
+ +
+
+
Implemented wedge
+

One narrow corridor first: bank → sending VASP → chain → receiving VASP.

+
+
+ +
Travel Rulepacs.008 data
+
Quotefees + slippage
+
Instructionlinked record
+
ChainPENDING → FINAL
+
Finalitycamt.025-like
+
StatusGET /instruction
+
Reportingcamt.054/052/053
+
Evidencereviewer pack
+
Demovisual console
+
+
Narrow lifecycle: identity, quote, instruction, chain, finality.
+
+ +
+
+
Tom alignment
+

Keep recognised ISO names where they clarify the blockchain remediation path.

+
+
+
Return

pacs.004 remains the industry-readable return vocabulary.

+
Reverse

pacs.007 stays aligned with post-settlement remediation.

+
Cancel

camt.056 is only realistic before public-chain broadcast.

+
+
Return and reversal stay aligned with pacs.004 and pacs.007.
+
+ +
+
+
Investigation boundary
+

The exception-family layer stays useful, but not locked into the spec yet.

+
+
+ +
InstructionFINAL
+
Return requestPOST /instruction/{instructionId}/return
+
Reversal statusPOST /instruction/{instructionId}/reverseGET /instruction/{instructionId}/reversal-status
+
Investigation familycamt.026 / 027 / 087 TBD
+
draft input, not normative lock-in
+
+
Investigation and E&I flows stay draft until the API shape is agreed.
+
+ +
+
+
Reviewer package
+

The visual layer points reviewers to what is implemented, partial, and still draft.

+
+
+
Implemented

Reference server routes, simulators, happy-path evidence, status, finality, reporting.

+
Partial

Pre-broadcast cancellation window and testnet execution caveats.

+
Draft

Richer investigation and request-for-information family.

+
+
Green is implemented, amber is partial, rose remains draft.
+
+ +
+
+
Next review question
+

Is this visual format useful for spec and reference-implementation discussion?

+
+
+
Lifecycle map
+
Sequence diagram
+
Return/reversal split
+
Investigation TBD
+
+
Next pass can focus on E&I and liquidity management.
+
+
+ + + + \ No newline at end of file diff --git a/videos/visual-flow-map/renders/pacs-crypto-visual-flow-tom-preview.mp4 b/videos/visual-flow-map/renders/pacs-crypto-visual-flow-tom-preview.mp4 new file mode 100644 index 0000000..2018c84 Binary files /dev/null and b/videos/visual-flow-map/renders/pacs-crypto-visual-flow-tom-preview.mp4 differ diff --git a/videos/visual-flow-map/renders/preview-19s.png b/videos/visual-flow-map/renders/preview-19s.png new file mode 100644 index 0000000..5bff36b Binary files /dev/null and b/videos/visual-flow-map/renders/preview-19s.png differ diff --git a/videos/visual-flow-map/renders/preview-20s.png b/videos/visual-flow-map/renders/preview-20s.png new file mode 100644 index 0000000..bf2edda Binary files /dev/null and b/videos/visual-flow-map/renders/preview-20s.png differ diff --git a/videos/visual-flow-map/renders/preview-subtitles-clean-20s.png b/videos/visual-flow-map/renders/preview-subtitles-clean-20s.png new file mode 100644 index 0000000..6ddb4a8 Binary files /dev/null and b/videos/visual-flow-map/renders/preview-subtitles-clean-20s.png differ diff --git a/videos/visual-flow-map/renders/preview-subtitles-clean-24s.png b/videos/visual-flow-map/renders/preview-subtitles-clean-24s.png new file mode 100644 index 0000000..a0ee283 Binary files /dev/null and b/videos/visual-flow-map/renders/preview-subtitles-clean-24s.png differ diff --git a/videos/visual-flow-map/renders/preview-tom-15s.png b/videos/visual-flow-map/renders/preview-tom-15s.png new file mode 100644 index 0000000..d3f7c67 Binary files /dev/null and b/videos/visual-flow-map/renders/preview-tom-15s.png differ diff --git a/videos/visual-flow-map/renders/preview-tom-17s.png b/videos/visual-flow-map/renders/preview-tom-17s.png new file mode 100644 index 0000000..8f9a558 Binary files /dev/null and b/videos/visual-flow-map/renders/preview-tom-17s.png differ diff --git a/videos/visual-flow-map/renders/preview-tom-25s.png b/videos/visual-flow-map/renders/preview-tom-25s.png new file mode 100644 index 0000000..64fbcfd Binary files /dev/null and b/videos/visual-flow-map/renders/preview-tom-25s.png differ diff --git a/videos/visual-flow-map/renders/preview-tom-27s.png b/videos/visual-flow-map/renders/preview-tom-27s.png new file mode 100644 index 0000000..39169cc Binary files /dev/null and b/videos/visual-flow-map/renders/preview-tom-27s.png differ