From f4712b6da6e59e13ea0565cf8fcc50e66a049e53 Mon Sep 17 00:00:00 2001 From: llabori-venehsoftw Date: Sun, 22 Mar 2026 18:36:00 -0300 Subject: [PATCH] feat(lazer/cardano): add IntegralPayments gateway by VeneHsoftw --- lazer/cardano/integral-payments/README.md | 295 ++++++++++++ .../integral-payments/contracts/aiken.toml | 14 + .../contracts/lib/pyth/types.ak | 61 +++ .../contracts/lib/pyth/verification.ak | 181 ++++++++ .../contracts/validators/payment_gateway.ak | 177 ++++++++ .../validators/test/payment_gateway_tests.ak | 268 +++++++++++ lazer/cardano/integral-payments/package.json | 40 ++ .../scripts/check-validator.ts | 209 +++++++++ .../integral-payments/scripts/deploy.ts | 202 +++++++++ .../integral-payments/scripts/e2e-test.ts | 300 +++++++++++++ .../scripts/generate-wallet.ts | 160 +++++++ .../integral-payments/scripts/utils.ts | 261 +++++++++++ .../scripts/verify-pyth-feeds.ts | 250 +++++++++++ .../src/cardano/transaction.ts | 389 ++++++++++++++++ lazer/cardano/integral-payments/src/config.ts | 122 +++++ .../src/gateway/paymentService.ts | 423 ++++++++++++++++++ lazer/cardano/integral-payments/src/index.ts | 44 ++ .../src/oracle/pythClient.ts | 373 +++++++++++++++ .../src/tests/paymentService.test.ts | 278 ++++++++++++ .../src/tests/pythClient.test.ts | 236 ++++++++++ lazer/cardano/integral-payments/src/types.ts | 153 +++++++ lazer/cardano/integral-payments/tsconfig.json | 34 ++ 22 files changed, 4470 insertions(+) create mode 100755 lazer/cardano/integral-payments/README.md create mode 100755 lazer/cardano/integral-payments/contracts/aiken.toml create mode 100755 lazer/cardano/integral-payments/contracts/lib/pyth/types.ak create mode 100755 lazer/cardano/integral-payments/contracts/lib/pyth/verification.ak create mode 100755 lazer/cardano/integral-payments/contracts/validators/payment_gateway.ak create mode 100755 lazer/cardano/integral-payments/contracts/validators/test/payment_gateway_tests.ak create mode 100755 lazer/cardano/integral-payments/package.json create mode 100755 lazer/cardano/integral-payments/scripts/check-validator.ts create mode 100755 lazer/cardano/integral-payments/scripts/deploy.ts create mode 100755 lazer/cardano/integral-payments/scripts/e2e-test.ts create mode 100755 lazer/cardano/integral-payments/scripts/generate-wallet.ts create mode 100755 lazer/cardano/integral-payments/scripts/utils.ts create mode 100755 lazer/cardano/integral-payments/scripts/verify-pyth-feeds.ts create mode 100755 lazer/cardano/integral-payments/src/cardano/transaction.ts create mode 100755 lazer/cardano/integral-payments/src/config.ts create mode 100755 lazer/cardano/integral-payments/src/gateway/paymentService.ts create mode 100755 lazer/cardano/integral-payments/src/index.ts create mode 100755 lazer/cardano/integral-payments/src/oracle/pythClient.ts create mode 100755 lazer/cardano/integral-payments/src/tests/paymentService.test.ts create mode 100755 lazer/cardano/integral-payments/src/tests/pythClient.test.ts create mode 100755 lazer/cardano/integral-payments/src/types.ts create mode 100755 lazer/cardano/integral-payments/tsconfig.json diff --git a/lazer/cardano/integral-payments/README.md b/lazer/cardano/integral-payments/README.md new file mode 100755 index 00000000..3ed0fc6b --- /dev/null +++ b/lazer/cardano/integral-payments/README.md @@ -0,0 +1,295 @@ +# IntegralPayments β€” Crypto Payment Gateway for ERP/CRM Platforms + +> **Hackathon Submission** | Pyth Network Γ— Input/Output (Cardano) Hackathon +> **Team:** VeneHsoftw +> **Project:** IntegralPayments +> **Team Menbers:** Eng. Luis LAbori +> **Contact:** cto@venehsoftw.site + +--- + +## πŸ“Œ Overview + +**IntegralPayments** is a modular payment gateway designed to be installed and enabled as a plugin/module on leading open-source ERP and CRM platforms β€” **Dolibarr**, **Tryton**, and **Odoo**. It integrates the **Pyth Network Oracle** on the **Cardano blockchain** to consume real-time and projected cryptocurrency price feeds, enabling businesses to accept payments in digital assets directly through their ERP/CRM environment. + +By leveraging Pyth's decentralized price oracle on Cardano, IntegralPayments provides accurate, low-latency asset valuations at the point of payment β€” eliminating the risk of price slippage and ensuring fair exchange rates for both merchants and customers. + +--- + +## πŸš€ Features + +- πŸ”Œ **Plug-and-play module** for Dolibarr, Tryton, and Odoo +- πŸ“‘ **Pyth Network Oracle integration** for real-time crypto price feeds on Cardano +- πŸͺ™ **Multi-asset support** β€” accepts payments in ADA and other Cardano-native tokens +- πŸ”„ **Automatic fiat-to-crypto conversion** at the moment of checkout using live Pyth price data +- 🧾 **Invoice reconciliation** β€” automatically registers crypto payments in the ERP/CRM ledger at the exchange rate used +- πŸ” **Non-custodial** β€” funds go directly to the merchant's wallet; no intermediary holds assets +- πŸ“Š **Payment history dashboard** integrated within each ERP/CRM module +- βš™οΈ **Configurable asset whitelist** β€” administrators select which cryptocurrencies to accept + +--- + +## πŸ—οΈ Architecture + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ ERP / CRM Platform (Dolibarr / Tryton / Odoo)β”‚ +β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ IntegralPayments Module β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ +β”‚ β”‚ β”‚ Checkout UI │────▢│ Price Feed Resolver β”‚ β”‚ β”‚ +β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ Pyth Hermes API (off-chain) β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ +β”‚ β”‚ β”‚ Cardano Smart Contract (Aiken) β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ - Validates Pyth price update proof β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ - Verifies payment amount vs. fiat value β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ - Emits payment confirmation event β”‚ β”‚ β”‚ +β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +**Flow:** +1. Customer selects "Pay with Crypto" at checkout. +2. The IntegralPayments module queries the **Pyth Hermes API** for the latest signed price update for the chosen asset pair (e.g., `ADA/USD`). +3. The module calculates the exact crypto amount equivalent to the invoice's fiat value. +4. The customer sends the transaction on Cardano. +5. The **Aiken smart contract** on-chain verifies the Pyth price proof and validates that the received amount matches the expected value within an acceptable tolerance window. +6. Upon successful validation, the contract emits a confirmation event. +7. The ERP/CRM module listens for the confirmation, marks the invoice as paid, and records the exchange rate used. + +--- + +## πŸ› οΈ Technology Stack + +| Layer | Technology | +|---|---| +| Blockchain | Cardano (Mainnet / Preprod Testnet) | +| Smart Contract Language | Aiken | +| Oracle | Pyth Network β€” Pyth Core (Pull Model) | +| Price Feed API | Pyth Hermes | +| Off-chain Backend | Node.js / TypeScript | +| ERP/CRM Integration | Python (Odoo module), PHP (Dolibarr module), Python (Tryton module) | +| Wallet Interaction | Lucid Evolution (Cardano off-chain library) | +| Testing | Aiken built-in test framework, Jest | + +--- + +## πŸ”— Pyth Integration Details + +IntegralPayments uses **Pyth Core** in **Pull Update** mode on Cardano. + +### Price Feeds Used + +| Asset Pair | Pyth Price Feed ID | +|---|---| +| ADA/USD | `2a01deaec9e51a579277b34b122399984d0bbf57e2458a7e42fecd2829867a0d` | +| BTC/USD | `e62df6c8b4a85fe1a67db44dc12de5db330f7ac66b72dc658afedf0f4a415b43` | +| ETH/USD | `ff61491a931112ddf1bd8147cd1b641375f79f5825126d665480874634fd0ace` | +| USDC/USD | `eaa020c61cc479712813461ce153894a96a6c00b21ed0cfc2798d1f9a9e9c94a` | + +### How Price Updates Are Fetched + +```typescript +import { HermesClient } from "@pythnetwork/hermes-client"; + +const hermesClient = new HermesClient("https://hermes.pyth.network"); + +// Fetch the latest price update VAA for ADA/USD +const priceUpdateData = await hermesClient.getLatestPriceUpdates([ + "0x2a01deaec9e51a579277b34b122399984d0bbf57e2458a7e42fecd2829867a0d", +]); + +const adaUsdPrice = priceUpdateData.parsed[0].price; +const exponent = priceUpdateData.parsed[0].exponent; +const priceValue = adaUsdPrice * Math.pow(10, exponent); +``` + +### Smart Contract Price Verification (Aiken β€” pseudocode) + +```aiken +// Verify that the payment amount satisfies the invoice +// using a Pyth price proof submitted with the transaction +fn verify_payment( + invoice_amount_usd: Int, + paid_amount_lovelace: Int, + pyth_price_proof: PriceProof, + tolerance_bps: Int, +) -> Bool { + let ada_usd_price = pyth.verify_and_get_price(pyth_price_proof) + let expected_lovelace = invoice_amount_usd * 1_000_000 / ada_usd_price + let delta = abs(paid_amount_lovelace - expected_lovelace) + delta * 10_000 / expected_lovelace <= tolerance_bps +} +``` + +--- + +## πŸ“¦ Installation + +### Prerequisites + +- Node.js β‰₯ 18 +- A Cardano wallet (Nami, Eternl, or compatible) +- Access to a Cardano node or a public RPC (e.g., Blockfrost) +- One of the supported ERP/CRM platforms installed + +### 1. Clone the Repository + +```bash +git clone https://github.com/venehsoftw/integral-payments.git +cd integral-payments +``` + +### 2. Install Dependencies + +```bash +npm install +``` + +### 3. Configure Environment + +```bash +cp .env.example .env +``` + +Edit `.env` and fill in: + +```env +NETWORK=preprod # or mainnet +BLOCKFROST_API_KEY=your_key_here +PYTH_HERMES_URL=https://hermes.pyth.network +MERCHANT_WALLET_ADDRESS=addr1... +TOLERANCE_BPS=50 # 0.5% price slippage tolerance +``` + +### 4. Deploy the Smart Contract + +```bash +cd contracts +aiken build +aiken check +node scripts/deploy.js +``` + +### 5. Install the ERP/CRM Module + +#### Dolibarr + +```bash +cp -r modules/dolibarr/integralpayments /path/to/dolibarr/htdocs/custom/ +``` + +Then activate the module from **Setup β†’ Modules/Applications β†’ IntegralPayments**. + +#### Odoo + +```bash +cp -r modules/odoo/integral_payments /path/to/odoo/addons/ +``` + +Then install from **Apps β†’ Update App List β†’ Search "IntegralPayments"**. + +#### Tryton + +```bash +pip install trytond-integral-payments +``` + +Then activate from **Administration β†’ Modules β†’ IntegralPayments**. + +--- + +## πŸ§ͺ Running Tests + +```bash +# Smart contract tests (Aiken) +cd contracts && aiken check + +# Off-chain unit and integration tests +npm test + +# Run a full end-to-end test on Preprod Testnet +npm run test:e2e +``` + +--- + +## πŸ“ Project Structure + +``` +integral-payments/ +β”œβ”€β”€ contracts/ # Aiken smart contracts +β”‚ β”œβ”€β”€ lib/ +β”‚ β”‚ └── pyth/ # Pyth price proof validation helpers +β”‚ β”œβ”€β”€ validators/ +β”‚ β”‚ └── payment_gateway.ak # Main payment validator +β”‚ └── aiken.toml +β”œβ”€β”€ src/ # Off-chain TypeScript backend +β”‚ β”œβ”€β”€ oracle/ +β”‚ β”‚ └── pythClient.ts # Pyth Hermes API client +β”‚ β”œβ”€β”€ cardano/ +β”‚ β”‚ └── transaction.ts # Lucid transaction builder +β”‚ └── gateway/ +β”‚ └── paymentService.ts # Core payment orchestration +β”œβ”€β”€ modules/ +β”‚ β”œβ”€β”€ dolibarr/ # Dolibarr PHP module +β”‚ β”œβ”€β”€ odoo/ # Odoo Python module +β”‚ └── tryton/ # Tryton Python module +β”œβ”€β”€ scripts/ # Deployment and utility scripts +β”œβ”€β”€ tests/ # Test suite +β”œβ”€β”€ .env.example +└── README.md +``` + +--- + +## 🌐 Live Demo + +| Environment | URL | +| --- | --- | +| Demo ERP (Dolibarr Preprod) | _Coming soon_ | +| Smart Contract (Preprod) | `addr_test1...` _(to be updated post-deployment)_ | +| Pyth Price Feed Explorer | https://pyth.network/price-feeds | + +--- + +## πŸ‘₯ Team β€” VeneHsoftw + +| Name | Role | +| --- | --- | +| VeneHsoftw Core Team | +| Smart Contract Development | TBD | +| Offchain Engineer | TBD | +| BackEnd Engineer | TDB | +| ERP/CRM Modules developer | TDB | +| Security Auditor | TBD | +| QA Tester Engineer | TBD | + + +πŸ“§ Contact: [info@venehsoftw.site](mailto:info@venehsoftw.site) +🌍 Organization: VeneHsoftw + +--- + +## πŸ“„ License + +This project is licensed under the **Apache 2.0 License**. See [LICENSE](./LICENSE) for details. + +--- + +## πŸ™ Acknowledgements + +- [Pyth Network](https://pyth.network) β€” for providing the decentralized oracle infrastructure and the Hermes API. +- [Input/Output (IOG)](https://iohk.io) β€” for building the Cardano blockchain and the Aiken smart contract language. +- [Lucid Evolution](https://lucid.spacebudz.io) β€” for the off-chain Cardano transaction library. +- The open-source communities behind **Dolibarr**, **Odoo**, and **Tryton**. + +--- + +> _IntegralPayments β€” Bringing real-time crypto payment rails to the world of open-source ERP/CRM._ diff --git a/lazer/cardano/integral-payments/contracts/aiken.toml b/lazer/cardano/integral-payments/contracts/aiken.toml new file mode 100755 index 00000000..bffd9219 --- /dev/null +++ b/lazer/cardano/integral-payments/contracts/aiken.toml @@ -0,0 +1,14 @@ +name = "venehsoftw/integral-payments" +version = "0.1.0" +license = "Apache-2.0" +description = "IntegralPayments β€” Pyth-oracle-backed crypto payment gateway for ERP/CRM platforms on Cardano" + +[repository] +user = "venehsoftw" +project = "integral-payments" +platform = "github" + +[[dependencies]] +name = "aiken-lang/stdlib" +version = "v3" +source = "github" diff --git a/lazer/cardano/integral-payments/contracts/lib/pyth/types.ak b/lazer/cardano/integral-payments/contracts/lib/pyth/types.ak new file mode 100755 index 00000000..6a07773d --- /dev/null +++ b/lazer/cardano/integral-payments/contracts/lib/pyth/types.ak @@ -0,0 +1,61 @@ +/// pyth/types.ak +/// +/// Core data structures that mirror the Pyth Lazer price-update payload. +/// +/// When the off-chain service fetches a signed price update from the Pyth +/// Hermes API it must serialise these fields into Cardano Plutus Data and +/// attach them as part of the transaction redeemer. The on-chain validator +/// then reads and verifies this data without any trusted intermediary. +/// +/// Field layout matches the Pyth Lazer wire format: +/// - feed_id : 32-byte hex identifier of the price feed +/// - price : raw integer price (must be combined with exponent) +/// - conf : confidence interval (same exponent applies) +/// - exponent : negative integer; real_price = price * 10^exponent +/// - publish_time : Unix timestamp (seconds) of the Pythnet observation +/// - signature : 64-byte Ed25519 signature produced by the Pyth signer +/// - signer_key : 32-byte Ed25519 public key of the authorised signer + +/// A single signed price observation delivered by the Pyth oracle. +pub type PriceProof { + /// 32-byte Pyth feed identifier (e.g. ADA/USD feed id) + feed_id: ByteArray, + /// Raw price mantissa β€” multiply by 10^exponent for the real value + price: Int, + /// Confidence interval mantissa β€” same exponent as `price` + conf: Int, + /// Negative power-of-10 scaling factor (e.g. -8 means Γ—10⁻⁸) + exponent: Int, + /// Unix timestamp (seconds) at which Pythnet observed the price + publish_time: Int, + /// Ed25519 signature over the canonical message (see `message_bytes`) + signature: ByteArray, + /// Ed25519 public key of the Pyth signer that produced `signature` + signer_key: ByteArray, +} + +/// Known Cardano-mainnet Pyth feed identifiers (hex-encoded, 32 bytes each). +/// These constants are embedded at compile-time so the validator can assert +/// that only recognised feeds are accepted for payment. +pub const ada_usd_feed_id: ByteArray = + #"2a01deaec9e51a579277b34b122399984d0bbf57e2458a7e42fecd2829867a0d" + +pub const btc_usd_feed_id: ByteArray = + #"e62df6c8b4a85fe1a67db44dc12de5db330f7ac66b72dc658afedf0f4a415b43" + +pub const eth_usd_feed_id: ByteArray = + #"ff61491a931112ddf1bd8147cd1b641375f79f5825126d665480874634fd0ace" + +pub const usdc_usd_feed_id: ByteArray = + #"eaa020c61cc479712813461ce153894a96a6c00b21ed0cfc2798d1f9a9e9c94a" + +/// Maximum age (in seconds) a price observation may have before the +/// validator rejects it as stale. Configured at 60 s for the POC; +/// tighten to 30 s or fewer in production. +pub const max_price_age_seconds: Int = 60 + +/// Lovelace per ADA (1 ADA = 1_000_000 lovelace) +pub const lovelace_per_ada: Int = 1_000_000 + +/// Basis-points denominator (10_000 bp = 100 %) +pub const bps_denominator: Int = 10_000 diff --git a/lazer/cardano/integral-payments/contracts/lib/pyth/verification.ak b/lazer/cardano/integral-payments/contracts/lib/pyth/verification.ak new file mode 100755 index 00000000..05bf0c2b --- /dev/null +++ b/lazer/cardano/integral-payments/contracts/lib/pyth/verification.ak @@ -0,0 +1,181 @@ +/// pyth/verification.ak +/// +/// On-chain verification helpers for Pyth Lazer price proofs. +/// +/// ## Verification strategy +/// +/// Pyth Lazer signs a canonical byte-message with an Ed25519 key. The +/// message is constructed as: +/// +/// message = feed_id (32 bytes) +/// || price_bytes ( 8 bytes, big-endian int64) +/// || conf_bytes ( 8 bytes, big-endian int64) +/// || exponent_bytes ( 4 bytes, big-endian int32) +/// || publish_time_bytes (8 bytes, big-endian int64) +/// +/// The validator calls `builtin.verify_ed25519_signature` (a Plutus V3 +/// native builtin) to confirm the signature without any off-chain trust. +/// +/// ## Important notes for auditors +/// +/// 1. The `signer_key` in the proof must match the `trusted_signer` parameter +/// embedded in the validator at deployment time. This prevents an attacker +/// from supplying a self-signed price proof. +/// +/// 2. `publish_time` is compared against `tx_valid_range` lower bound so the +/// validator rejects proofs whose timestamp is outside the current slot +/// window. Off-chain code MUST set a tight validity interval. +/// +/// 3. The confidence interval check (`conf <= price / 10`) is a basic sanity +/// guard; tighten or remove it according to the risk tolerance of the +/// deployment. + +use aiken/builtin +use aiken/math +use pyth/types.{ + PriceProof, + bps_denominator, + lovelace_per_ada, + max_price_age_seconds, +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +/// Serialise a 64-bit big-endian integer into 8 bytes. +/// Aiken's `builtin.integer_to_bytearray` encodes in little-endian, so we +/// reverse the output. +fn int64_to_bytes(n: Int) -> ByteArray { + let le = builtin.integer_to_bytearray(True, 8, n) + builtin.bytearray_to_integer(False, le) + |> builtin.integer_to_bytearray(False, 8, _) +} + +/// Serialise a 32-bit big-endian integer into 4 bytes. +fn int32_to_bytes(n: Int) -> ByteArray { + let le = builtin.integer_to_bytearray(True, 4, n) + builtin.bytearray_to_integer(False, le) + |> builtin.integer_to_bytearray(False, 4, _) +} + +/// Build the canonical Pyth Lazer message that was signed. +/// +/// Layout (60 bytes total): +/// [0..31] feed_id (32 bytes) +/// [32..39] price ( 8 bytes, big-endian int64) +/// [40..47] conf ( 8 bytes, big-endian int64) +/// [48..51] exponent ( 4 bytes, big-endian int32) +/// [52..59] publish_time ( 8 bytes, big-endian int64) +pub fn build_message(proof: PriceProof) -> ByteArray { + proof.feed_id + |> builtin.append_bytearray(int64_to_bytes(proof.price), _) + |> builtin.append_bytearray(int64_to_bytes(proof.conf), _) + |> builtin.append_bytearray(int32_to_bytes(proof.exponent), _) + |> builtin.append_bytearray(int64_to_bytes(proof.publish_time), _) +} + +// --------------------------------------------------------------------------- +// Public verification API +// --------------------------------------------------------------------------- + +/// Verify that the Ed25519 signature in `proof` is valid for the canonical +/// Pyth Lazer message, signed with `proof.signer_key`. +/// +/// Returns `True` iff the cryptographic check passes. +pub fn verify_signature(proof: PriceProof) -> Bool { + let msg = build_message(proof) + builtin.verify_ed25519_signature(proof.signer_key, msg, proof.signature) +} + +/// Assert that `proof.signer_key` matches the `trusted_signer` key that was +/// burned into the validator at deployment time. +/// +/// This guards against self-signed proofs: an attacker could otherwise +/// generate a valid Ed25519 signature with their own key and supply a +/// fabricated price. +pub fn verify_signer(proof: PriceProof, trusted_signer: ByteArray) -> Bool { + proof.signer_key == trusted_signer +} + +/// Assert that the price feed targeted by `proof.feed_id` matches the +/// `expected_feed_id` configured on the payment UTxO datum. +pub fn verify_feed_id(proof: PriceProof, expected_feed_id: ByteArray) -> Bool { + proof.feed_id == expected_feed_id +} + +/// Assert that `proof.publish_time` is not older than `max_price_age_seconds` +/// relative to the current slot's lower bound (passed in as `now_seconds`). +/// +/// The off-chain service must derive `now_seconds` from the transaction +/// validity interval lower bound so this check is deterministic on-chain. +pub fn verify_freshness(proof: PriceProof, now_seconds: Int) -> Bool { + let age = now_seconds - proof.publish_time + age >= 0 && age <= max_price_age_seconds +} + +/// Sanity check: confidence interval must not exceed 10 % of the price. +/// A wide confidence interval signals low liquidity or a market disruption +/// and should block payment acceptance. +pub fn verify_confidence(proof: PriceProof) -> Bool { + // conf <= price / 10 ↔ conf * 10 <= price + proof.conf * 10 <= proof.price +} + +/// Compute how many lovelace are required to settle `invoice_usd_cents` +/// (an integer number of US-cent units, e.g. $12.50 = 1250) at the price +/// contained in `proof`. +/// +/// Formula: +/// lovelace = invoice_usd_cents +/// Γ— lovelace_per_ada +/// Γ— 10^(-exponent) ← undo the Pyth price exponent +/// Γ· (price Γ— 100) ← price is per ADA, cents need Γ·100 +/// +/// Since `exponent` is negative (e.g. -8), `10^(-exponent)` is a large +/// integer (10^8) β€” we avoid floating point entirely. +/// +/// Example: +/// proof.price = 35_000_000 (= $0.35000000 with exponent -8) +/// proof.exponent = -8 +/// invoice = 1000 cents (= $10.00) +/// +/// scale = 10^8 = 100_000_000 +/// lovelace = 1000 Γ— 1_000_000 Γ— 100_000_000 +/// Γ· (35_000_000 Γ— 100) +/// = 100_000_000_000_000 Γ· 3_500_000_000 +/// β‰ˆ 28_571_428 (β‰ˆ 28.57 ADA) +pub fn compute_required_lovelace( + proof: PriceProof, + invoice_usd_cents: Int, +) -> Int { + // exponent is negative; negate it to get the positive scaling power + let scale = math.pow(10, 0 - proof.exponent) + invoice_usd_cents * lovelace_per_ada * scale / (proof.price * 100) +} + +/// Verify that `paid_lovelace` satisfies the invoice for `invoice_usd_cents` +/// at the oracle price, within `tolerance_bps` basis points of slippage. +/// +/// The tolerance window is expressed in basis points (1 bp = 0.01 %). +/// A value of 50 allows up to 0.50 % price movement between the moment the +/// off-chain service fetches the price and the moment the transaction lands. +/// +/// Returns `True` iff: +/// paid_lovelace β‰₯ required Γ— (bps_denominator - tolerance_bps) +/// Γ· bps_denominator +/// +/// i.e. the merchant accepts a small shortfall due to price movement but +/// never accepts less than (1 - tolerance) Γ— expected. +pub fn verify_payment_amount( + proof: PriceProof, + invoice_usd_cents: Int, + paid_lovelace: Int, + tolerance_bps: Int, +) -> Bool { + let required = compute_required_lovelace(proof, invoice_usd_cents) + // lower_bound = required * (10_000 - tolerance_bps) / 10_000 + let lower_bound = + required * (bps_denominator - tolerance_bps) / bps_denominator + paid_lovelace >= lower_bound +} diff --git a/lazer/cardano/integral-payments/contracts/validators/payment_gateway.ak b/lazer/cardano/integral-payments/contracts/validators/payment_gateway.ak new file mode 100755 index 00000000..ab6b174b --- /dev/null +++ b/lazer/cardano/integral-payments/contracts/validators/payment_gateway.ak @@ -0,0 +1,177 @@ +/// validators/payment_gateway.ak +/// +/// IntegralPayments β€” main on-chain validator +/// +/// ## Overview +/// +/// This spend validator controls UTxOs that represent pending payment +/// requests created by the off-chain ERP/CRM gateway service. A UTxO is +/// locked here when a customer chooses to pay a merchant invoice in crypto. +/// It is unlocked (collected by the merchant) once the customer proves that +/// they have sent the correct amount, verified against a live Pyth oracle +/// price proof. +/// +/// ## UTxO lifecycle +/// +/// 1. The off-chain service (paymentService.ts) builds a "lock" transaction +/// that sends a small ADA deposit to this validator address, embedding a +/// `PaymentDatum` that records the invoice details. +/// +/// 2. The customer's wallet builds a "collect" transaction that: +/// a. Spends the locked UTxO (triggering this validator). +/// b. Includes the current Pyth price update in the redeemer. +/// c. Sends the required lovelace amount to the merchant's address. +/// d. Sets a tight validity interval so the on-chain staleness check +/// can compare `publish_time` to the slot lower bound. +/// +/// 3. The validator succeeds iff ALL of the following hold: +/// - The Pyth signer key matches the trusted key embedded at deploy time. +/// - The price proof's Ed25519 signature is cryptographically valid. +/// - The feed id in the proof matches the one recorded in the datum. +/// - The price observation is not older than `max_price_age_seconds`. +/// - The confidence interval is within the 10 % guard band. +/// - The lovelace sent to the merchant covers the invoice within the +/// configured slippage tolerance (default 50 bp = 0.50 %). +/// - The transaction carries the customer's signature. +/// +/// ## Parameters (burned in at deploy time via `aiken blueprint apply`) +/// +/// - `trusted_signer` : 32-byte Ed25519 public key of the Pyth oracle signer. +/// - `tolerance_bps` : acceptable price slippage in basis points (e.g. 50). +/// +/// ## Security considerations +/// +/// - Double-satisfaction is prevented: the datum carries a unique `payment_id` +/// derived from the ERP invoice reference. Off-chain code MUST assert that +/// only one input per `payment_id` is included per transaction. +/// - The merchant address is locked into the datum; anyone can submit the +/// collect transaction but ADA always flows to the merchant. +/// - The customer PKH requirement prevents front-running by third parties. +/// - The trusted_signer check prevents self-signed price proofs. + +use aiken/collection/list +use aiken/crypto.{VerificationKeyHash} +use cardano/address.{Address} +use cardano/assets +use cardano/transaction.{Output, OutputReference, Transaction} +use pyth/types.{PriceProof} +use pyth/verification.{ + verify_confidence, verify_feed_id, verify_freshness, verify_payment_amount, + verify_signature, verify_signer, +} + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/// Data locked in the UTxO when a payment request is created. +pub type PaymentDatum { + /// Unique identifier derived off-chain from the ERP invoice reference. + /// Prevents double-satisfaction: each invoice maps to exactly one UTxO. + payment_id: ByteArray, + /// Bech32-decoded merchant address that must receive the payment. + merchant_address: Address, + /// Invoice amount in US-cent integer units (e.g. $10.50 -> 1050). + /// Conversion to lovelace is performed at settlement using the live price. + invoice_usd_cents: Int, + /// 32-byte Pyth feed identifier for the asset accepted for this invoice. + /// Must equal `proof.feed_id` in the redeemer at settlement time. + accepted_feed_id: ByteArray, + /// Verification key hash of the customer allowed to settle this invoice. + /// Must appear in `extra_signatories` of the collect transaction. + customer_pkh: VerificationKeyHash, + /// Unix timestamp (seconds) when the payment request was created. + /// Off-chain reference only; not validated on-chain. + created_at: Int, +} + +/// Data supplied by the customer when spending the locked UTxO. +pub type PaymentRedeemer { + /// Signed Pyth price observation for the invoice's accepted asset feed. + proof: PriceProof, + /// Lower bound of the transaction validity interval as Unix seconds. + /// The off-chain builder MUST derive this from the slot's POSIX lower bound + /// so the freshness check is deterministic on-chain. + now_seconds: Int, +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +/// Sum the lovelace of every output directed to `merchant_address`. +fn merchant_lovelace_received( + outputs: List, + merchant_address: Address, +) -> Int { + list.foldr( + outputs, + 0, + fn(o, acc) { + if o.address == merchant_address { + acc + assets.lovelace_of(o.value) + } else { + acc + } + }, + ) +} + +// --------------------------------------------------------------------------- +// Validator +// --------------------------------------------------------------------------- + +/// `payment_gateway` - IntegralPayments spend validator. +/// +/// Parameters +/// ---------- +/// trusted_signer : ByteArray +/// The 32-byte Ed25519 public key of the Pyth Lazer oracle signer. +/// Obtain from Pyth's published signer key set and embed via +/// `aiken blueprint apply`. +/// +/// tolerance_bps : Int +/// Price-slippage tolerance in basis points (1 bp = 0.01 %). +/// Recommended: 50 for the POC (allows 0.50 % price movement). +validator payment_gateway(trusted_signer: ByteArray, tolerance_bps: Int) { + spend( + datum_opt: Option, + redeemer: PaymentRedeemer, + _utxo: OutputReference, + self: Transaction, + ) { + // 1. Datum must be present + expect Some(datum) = datum_opt + + let proof = redeemer.proof + let now = redeemer.now_seconds + + // 2. The price proof must come from our trusted Pyth signer key + expect verify_signer(proof, trusted_signer) + + // 3. Ed25519 signature over the canonical Pyth Lazer message must be valid + expect verify_signature(proof) + + // 4. Price feed must match the one accepted for this invoice + expect verify_feed_id(proof, datum.accepted_feed_id) + + // 5. Price observation must not be stale + expect verify_freshness(proof, now) + + // 6. Confidence interval must be within the 10 % guard band + expect verify_confidence(proof) + + // 7. Lovelace received by merchant must cover the invoice within tolerance + let received = + merchant_lovelace_received(self.outputs, datum.merchant_address) + expect verify_payment_amount(proof, datum.invoice_usd_cents, received, tolerance_bps) + + // 8. Transaction must be signed by the authorised customer + list.has(self.extra_signatories, datum.customer_pkh) + } + + // Reject any attempt to use this script for minting, withdrawals, etc. + else(_) { + fail @"payment_gateway: unsupported script purpose" + } +} diff --git a/lazer/cardano/integral-payments/contracts/validators/test/payment_gateway_tests.ak b/lazer/cardano/integral-payments/contracts/validators/test/payment_gateway_tests.ak new file mode 100755 index 00000000..03472848 --- /dev/null +++ b/lazer/cardano/integral-payments/contracts/validators/test/payment_gateway_tests.ak @@ -0,0 +1,268 @@ +/// validators/tests/payment_gateway_tests.ak +/// +/// Unit tests for the IntegralPayments validator and Pyth verification +/// library. Run with: +/// +/// cd contracts && aiken check +/// +/// Tests are organised in three groups: +/// 1. pyth/verification helpers (unit-level) +/// 2. payment_gateway validator happy paths +/// 3. payment_gateway validator failure / attack paths + +use aiken/collection/list +use aiken/crypto.{VerificationKeyHash} +use cardano/address.{Address, Inline, VerificationKey} +use cardano/assets +use cardano/transaction.{ + NoDatum, Output, OutputReference, Transaction, TransactionId, +} +use pyth/types.{ + PriceProof, ada_usd_feed_id, bps_denominator, lovelace_per_ada, + max_price_age_seconds, +} +use pyth/verification.{ + build_message, compute_required_lovelace, verify_confidence, verify_feed_id, + verify_freshness, verify_payment_amount, verify_signer, +} + +// ============================================================================ +// Test fixtures +// ============================================================================ + +/// A synthetic Pyth Ed25519 signing key pair. +/// In a real test setup you would generate these with `aiken key-gen` or +/// derive them deterministically from a known seed. +/// NOTE: Do NOT use these keys on mainnet. +const test_signer_key: ByteArray = + #"d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a" + +const test_signer_secret: ByteArray = + #"9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae3d55" + +/// ADA/USD price feed id (real mainnet value). +const feed_id: ByteArray = ada_usd_feed_id + +/// Synthetic price: ADA = $0.35000000 (exponent = -8) +const price_mantissa: Int = 35_000_000 + +const price_exponent: Int = -8 + +const price_conf: Int = 175_000 + +/// Unix timestamp used as "now" in freshness tests. +const now_ts: Int = 1_700_000_000 + +/// A publish_time 30 seconds before "now" β€” within the freshness window. +const fresh_ts: Int = now_ts - 30 + +/// A publish_time 90 seconds before "now" β€” outside the freshness window. +const stale_ts: Int = now_ts - 90 + +/// Invoice: $10.00 = 1000 US cents. +const invoice_cents: Int = 1000 + +/// Tolerance: 50 bp = 0.50 %. +const tolerance_bps: Int = 50 + +/// Merchant address (synthetic payment-only address). +const merchant_pkh: ByteArray = + #"abcdef0123456789abcdef0123456789abcdef0123456789abcdef01" + +/// Customer verification key hash. +const customer_pkh: VerificationKeyHash = + #"1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn make_merchant_address() -> Address { + Address { + payment_credential: VerificationKey(merchant_pkh), + staking_credential: None, + } +} + +/// Build a minimal PriceProof. The `signature` field is set to 64 zero bytes +/// for tests that do NOT check the Ed25519 signature (the crypto builtin would +/// require a real signed message). +fn make_proof( + p: Int, + c: Int, + exp: Int, + ts: Int, + fid: ByteArray, +) -> PriceProof { + PriceProof { + feed_id: fid, + price: p, + conf: c, + exponent: exp, + publish_time: ts, + // 64 zero bytes β€” valid placeholder for non-signature tests + signature: #"0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + signer_key: test_signer_key, + } +} + +fn standard_proof() -> PriceProof { + make_proof(price_mantissa, price_conf, price_exponent, fresh_ts, feed_id) +} + +/// Build a minimal Output sending `lovelace` to the merchant. +fn merchant_output(lovelace: Int) -> Output { + Output { + address: make_merchant_address(), + value: assets.from_lovelace(lovelace), + datum: NoDatum, + reference_script: None, + } +} + +// ============================================================================ +// Group 1 β€” pyth/verification helpers +// ============================================================================ + +test verify_signer_matches() { + let proof = standard_proof() + verify_signer(proof, test_signer_key) +} + +test verify_signer_rejects_wrong_key() { + let proof = standard_proof() + let wrong_key = + #"ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + !verify_signer(proof, wrong_key) +} + +test verify_feed_id_matches() { + let proof = standard_proof() + verify_feed_id(proof, ada_usd_feed_id) +} + +test verify_feed_id_rejects_wrong_feed() { + let proof = standard_proof() + let btc_feed = + #"e62df6c8b4a85fe1a67db44dc12de5db330f7ac66b72dc658afedf0f4a415b43" + !verify_feed_id(proof, btc_feed) +} + +test verify_freshness_within_window() { + let proof = standard_proof() + verify_freshness(proof, now_ts) +} + +test verify_freshness_rejects_stale_price() { + let proof = make_proof(price_mantissa, price_conf, price_exponent, stale_ts, feed_id) + !verify_freshness(proof, now_ts) +} + +test verify_freshness_rejects_future_timestamp() { + // publish_time > now_seconds is suspicious β€” age would be negative + let future_ts = now_ts + 10 + let proof = make_proof(price_mantissa, price_conf, price_exponent, future_ts, feed_id) + !verify_freshness(proof, now_ts) +} + +test verify_confidence_passes_tight_spread() { + // conf = 1 % of price β€” well within 10 % + let proof = make_proof(price_mantissa, price_mantissa / 100, price_exponent, fresh_ts, feed_id) + verify_confidence(proof) +} + +test verify_confidence_passes_at_exactly_10pct() { + // conf = exactly 10 % of price β†’ conf * 10 = price β†’ passes + let proof = make_proof(price_mantissa, price_mantissa / 10, price_exponent, fresh_ts, feed_id) + verify_confidence(proof) +} + +test verify_confidence_rejects_wide_spread() { + // conf = 15 % of price β†’ conf * 10 > price β†’ fails + let conf15pct = price_mantissa * 15 / 100 + let proof = make_proof(price_mantissa, conf15pct, price_exponent, fresh_ts, feed_id) + !verify_confidence(proof) +} + +// ============================================================================ +// Group 2 β€” compute_required_lovelace +// ============================================================================ + +test compute_lovelace_ada_usd() { + // ADA = $0.35 (price 35_000_000, exp -8) + // Invoice = $10.00 (1000 cents) + // Expected: 1000 * 1_000_000 * 10^8 / (35_000_000 * 100) + // = 100_000_000_000_000 / 3_500_000_000 + // = 28_571_428 lovelace (β‰ˆ 28.57 ADA) + let proof = standard_proof() + let result = compute_required_lovelace(proof, invoice_cents) + // Allow Β±1 lovelace for integer division rounding + result >= 28_571_427 && result <= 28_571_429 +} + +test compute_lovelace_higher_ada_price() { + // ADA = $1.00 (price 100_000_000, exp -8) + // Invoice = $10.00 β†’ expect 10 ADA = 10_000_000 lovelace + let proof = make_proof(100_000_000, 1_000_000, price_exponent, fresh_ts, feed_id) + let result = compute_required_lovelace(proof, invoice_cents) + result >= 9_999_999 && result <= 10_000_001 +} + +// ============================================================================ +// Group 3 β€” verify_payment_amount +// ============================================================================ + +test payment_amount_exact_passes() { + let proof = standard_proof() + let required = compute_required_lovelace(proof, invoice_cents) + verify_payment_amount(proof, invoice_cents, required, tolerance_bps) +} + +test payment_amount_overpayment_passes() { + let proof = standard_proof() + let required = compute_required_lovelace(proof, invoice_cents) + // 5 % over β€” should always pass + verify_payment_amount(proof, invoice_cents, required * 105 / 100, tolerance_bps) +} + +test payment_amount_within_tolerance_passes() { + let proof = standard_proof() + let required = compute_required_lovelace(proof, invoice_cents) + // 0.40 % under β€” within 50 bp tolerance + let slightly_under = required * 9960 / 10000 + verify_payment_amount(proof, invoice_cents, slightly_under, tolerance_bps) +} + +test payment_amount_beyond_tolerance_fails() { + let proof = standard_proof() + let required = compute_required_lovelace(proof, invoice_cents) + // 1 % under β€” beyond 50 bp tolerance + let too_little = required * 9900 / 10000 + !verify_payment_amount(proof, invoice_cents, too_little, tolerance_bps) +} + +test payment_amount_zero_fails() { + let proof = standard_proof() + !verify_payment_amount(proof, invoice_cents, 0, tolerance_bps) +} + +// ============================================================================ +// Group 4 β€” build_message determinism +// ============================================================================ + +test build_message_is_deterministic() { + let proof = standard_proof() + build_message(proof) == build_message(proof) +} + +test build_message_differs_on_price_change() { + let proof1 = standard_proof() + let proof2 = make_proof(price_mantissa + 1, price_conf, price_exponent, fresh_ts, feed_id) + build_message(proof1) != build_message(proof2) +} + +test build_message_differs_on_timestamp_change() { + let proof1 = standard_proof() + let proof2 = make_proof(price_mantissa, price_conf, price_exponent, fresh_ts + 1, feed_id) + build_message(proof1) != build_message(proof2) +} diff --git a/lazer/cardano/integral-payments/package.json b/lazer/cardano/integral-payments/package.json new file mode 100755 index 00000000..61ba4570 --- /dev/null +++ b/lazer/cardano/integral-payments/package.json @@ -0,0 +1,40 @@ +{ + "name": "@venehsoftw/integral-payments", + "version": "0.1.0", + "description": "IntegralPayments off-chain backend β€” Pyth oracle + Cardano transaction builder", + "type": "module", + "license": "Apache-2.0", + "author": "VeneHsoftw", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "scripts": { + "build": "tsc --project tsconfig.json", + "dev": "tsx watch src/index.ts", + "start": "node dist/index.js", + "test": "vitest run", + "test:watch": "vitest", + "test:e2e": "vitest run --config vitest.e2e.config.ts", + "lint": "eslint src --ext .ts", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@lucid-evolution/lucid": "^0.4.29", + "@pythnetwork/hermes-client": "^1.3.0", + "dotenv": "^16.4.5" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "tsx": "^4.19.0", + "typescript": "^5.5.0", + "vitest": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + } +} diff --git a/lazer/cardano/integral-payments/scripts/check-validator.ts b/lazer/cardano/integral-payments/scripts/check-validator.ts new file mode 100755 index 00000000..2fb5899a --- /dev/null +++ b/lazer/cardano/integral-payments/scripts/check-validator.ts @@ -0,0 +1,209 @@ +/** + * scripts/check-validator.ts + * + * IntegralPayments β€” validator UTxO inspector. + * + * Queries all UTxOs currently locked at the payment gateway validator address + * and decodes their inline datums into human-readable payment-request records. + * + * Useful for: + * - Verifying that lock transactions landed correctly after `deploy.ts`. + * - Monitoring open (unsettled) payment requests from the command line. + * - Debugging datum encoding issues during development. + * + * Usage + * ───── + * npx tsx scripts/check-validator.ts + * npx tsx scripts/check-validator.ts --payment-id # filter by id + * npx tsx scripts/check-validator.ts --raw # print raw CBOR + * + * Environment variables required + * ─────────────────────────────── + * BLOCKFROST_API_KEY Blockfrost project id + * TRUSTED_SIGNER_KEY 32-byte hex Ed25519 Pyth signer key + * SERVICE_WALLET_SEED BIP-39 mnemonic (read-only queries β€” only used to init Lucid) + * MERCHANT_WALLET_ADDRESS Bech32 merchant wallet address + * NETWORK Mainnet | Preprod (default) | Preview + * TOLERANCE_BPS Slippage tolerance (default: 50) + */ + +import { + Data, + Constr, + type UTxO, +} from "@lucid-evolution/lucid"; +import { + abbrev, + applyValidatorParams, + checkBlockfrost, + err, + field, + formatAda, + header, + initLucid, + loadEnv, + log, + ok, + readBlueprint, + warn, +} from "./utils.js"; + +// --------------------------------------------------------------------------- +// Datum decoder +// --------------------------------------------------------------------------- + +/** + * Attempt to decode a raw inline datum CBOR string into a structured + * PaymentDatum record. + * + * Field order matches the on-chain Aiken type: + * payment_id, merchant_address, invoice_usd_cents, + * accepted_feed_id, customer_pkh, created_at + */ +function decodeDatum(raw: string): Record | null { + try { + const decoded = Data.from(raw); + if (!(decoded instanceof Constr) || decoded.index !== 0n) return null; + + const f = decoded.fields as unknown[]; + + // Field 2: merchant_address is a nested Constr for the Address type + const addrConstr = f[1] as Constr; + const paymentCredConstr = addrConstr?.fields?.[0] as Constr; + const merchantPkh = paymentCredConstr?.fields?.[0] as string ?? "unknown"; + + // Field 4: staking_credential (Constr(1,[]) = None, ignored for display) + + const createdAt = Number(f[5] as bigint); + const ageMinutes = Math.floor((Date.now() / 1000 - createdAt) / 60); + + return { + paymentId: f[0] as string, + merchantPkh: abbrev(merchantPkh), + invoiceUsdCents: Number(f[2] as bigint), + acceptedFeedId: abbrev(f[3] as string), + customerPkh: abbrev(f[4] as string), + createdAt: new Date(createdAt * 1000).toISOString(), + ageMinutes, + }; + } catch { + return null; + } +} + +/** Map a known feed id to its human-readable name. */ +function feedName(feedId: string): string { + const names: Record = { + "2a01deaec9e51a579277b34b122399984d0bbf57e2458a7e42fecd2829867a0d": "ADA/USD", + "e62df6c8b4a85fe1a67db44dc12de5db330f7ac66b72dc658afedf0f4a415b43": "BTC/USD", + "ff61491a931112ddf1bd8147cd1b641375f79f5825126d665480874634fd0ace": "ETH/USD", + "eaa020c61cc479712813461ce153894a96a6c00b21ed0cfc2798d1f9a9e9c94a": "USDC/USD", + }; + const short = feedId.replace("…", "").slice(0, 64); + return names[short] ?? `unknown (${feedId})`; +} + +// --------------------------------------------------------------------------- +// Entry point +// --------------------------------------------------------------------------- + +async function main(): Promise { + const filterPaymentId = (() => { + const idx = process.argv.indexOf("--payment-id"); + return idx !== -1 ? process.argv[idx + 1] : null; + })(); + const showRaw = process.argv.includes("--raw"); + + header("IntegralPayments β€” Validator UTxO Inspector"); + + const env = loadEnv(); + field("Network", env.network); + field("Blockfrost", env.blockfrostUrl); + + log("Checking Blockfrost connectivity…"); + await checkBlockfrost(env.blockfrostUrl, env.blockfrostApiKey); + ok("Blockfrost is healthy."); + + log("Reading blueprint and deriving validator address…"); + const blueprint = readBlueprint(); + const lucid = await initLucid(env); + const validator = applyValidatorParams(blueprint, env.trustedSignerKey, env.toleranceBps); + const validatorAddr = lucid.utils.validatorToAddress(validator); + const scriptHash = lucid.utils.validatorToScriptHash(validator); + + field("Validator address", validatorAddr); + field("Script hash", scriptHash); + + // Fetch all UTxOs + log(`Querying UTxOs at validator address…`); + const utxos: UTxO[] = await lucid.utxosAt(validatorAddr); + + if (utxos.length === 0) { + warn("No UTxOs found at the validator address."); + warn("Either no payments have been created, or all have been settled."); + return; + } + + ok(`Found ${utxos.length} UTxO(s).`); + + // Apply optional filter + const filtered = filterPaymentId + ? utxos.filter((u) => { + const datum = decodeDatum(u.datum ?? ""); + return datum?.paymentId === filterPaymentId; + }) + : utxos; + + if (filtered.length === 0) { + warn(`No UTxOs match payment-id: ${filterPaymentId}`); + return; + } + + // Print each UTxO + let index = 0; + for (const utxo of filtered) { + index++; + const utxoRef = `${utxo.txHash}#${utxo.outputIndex}`; + const lovelace = utxo.assets.lovelace ?? 0n; + + header(`UTxO ${index} / ${filtered.length}`); + field("UTxO ref", utxoRef); + field("ADA locked", formatAda(lovelace)); + + if (!utxo.datum) { + warn("No inline datum β€” this UTxO was not created by IntegralPayments."); + continue; + } + + if (showRaw) { + field("Raw datum", utxo.datum); + } + + const decoded = decodeDatum(utxo.datum); + if (!decoded) { + warn("Could not decode datum. Schema mismatch or unexpected constructor."); + if (!showRaw) field("Raw datum", utxo.datum.slice(0, 80) + "…"); + continue; + } + + field("Payment ID", decoded.paymentId as string); + field("Feed", feedName(decoded.acceptedFeedId as string)); + field("Invoice (cents)", `$${((decoded.invoiceUsdCents as number) / 100).toFixed(2)}`); + field("Merchant PKH", decoded.merchantPkh as string); + field("Customer PKH", decoded.customerPkh as string); + field("Created at", decoded.createdAt as string); + field("Age", `${decoded.ageMinutes} min`); + + if ((decoded.ageMinutes as number) > 30) { + warn("This payment request is over 30 minutes old β€” may be expired."); + } + } + + console.log(); + ok("Inspection complete."); +} + +main().catch((e) => { + err(String(e)); + process.exit(1); +}); diff --git a/lazer/cardano/integral-payments/scripts/deploy.ts b/lazer/cardano/integral-payments/scripts/deploy.ts new file mode 100755 index 00000000..e8fdf0ad --- /dev/null +++ b/lazer/cardano/integral-payments/scripts/deploy.ts @@ -0,0 +1,202 @@ +/** + * scripts/deploy.ts + * + * IntegralPayments β€” validator deployment script. + * + * What this script does + * ───────────────────── + * 1. Reads the compiled blueprint from contracts/plutus.json. + * 2. Applies the two runtime parameters (trustedSignerKey, toleranceBps) + * to produce the final parameterised CBOR. + * 3. Derives the validator script address for the selected network. + * 4. Optionally publishes the script as a reference UTxO (saves ~1 ADA per + * collect transaction by allowing reference-script spending). + * 5. Writes the parameterised CBOR and derived address to: + * - .env.deployed (for the gateway service) + * - deployment.json (for auditors and the PR README) + * + * Prerequisites + * ───────────── + * cd contracts && aiken build # produces contracts/plutus.json + * cp .env.example .env # fill in required variables + * + * Usage + * ───── + * npx tsx scripts/deploy.ts [--publish-reference-script] + * + * Environment variables required + * ─────────────────────────────── + * BLOCKFROST_API_KEY Blockfrost project id + * TRUSTED_SIGNER_KEY 32-byte hex Ed25519 Pyth signer key + * SERVICE_WALLET_SEED BIP-39 mnemonic of the service wallet + * MERCHANT_WALLET_ADDRESS Bech32 merchant wallet address + * NETWORK Mainnet | Preprod (default) | Preview + * TOLERANCE_BPS Slippage tolerance (default: 50) + */ + +import fs from "node:fs"; +import path from "node:path"; +import { + abbrev, + applyValidatorParams, + checkBlockfrost, + err, + field, + formatAda, + header, + initLucid, + loadEnv, + log, + ok, + readBlueprint, + ROOT_DIR, + sleep, + warn, +} from "./utils.js"; + +// --------------------------------------------------------------------------- +// Entry point +// --------------------------------------------------------------------------- + +async function main(): Promise { + const publishRefScript = process.argv.includes("--publish-reference-script"); + + header("IntegralPayments β€” Validator Deployment"); + + // 1. Load environment + const env = loadEnv(); + field("Network", env.network); + field("Blockfrost URL", env.blockfrostUrl); + field("Tolerance", `${env.toleranceBps} bp (${env.toleranceBps / 100} %)`); + field("Trusted signer", abbrev(env.trustedSignerKey)); + field("Merchant addr", abbrev(env.merchantAddress, 12)); + + // 2. Verify Blockfrost connectivity + log("Checking Blockfrost connectivity…"); + await checkBlockfrost(env.blockfrostUrl, env.blockfrostApiKey); + ok("Blockfrost is healthy."); + + // 3. Read blueprint + log("Reading contracts/plutus.json…"); + const blueprint = readBlueprint(); + ok(`Blueprint loaded. Plutus version: ${blueprint.preamble.plutusVersion}`); + log(`Found validators: ${blueprint.validators.map((v) => v.title).join(", ")}`); + + // 4. Apply parameters to produce final CBOR + log("Applying parameters to validator…"); + const validator = applyValidatorParams(blueprint, env.trustedSignerKey, env.toleranceBps); + const validatorCbor = validator.script; + ok(`Parameterised CBOR length: ${validatorCbor.length / 2} bytes`); + + // 5. Derive validator address + log("Initialising Lucid and deriving validator address…"); + const lucid = await initLucid(env); + const validatorAddress = lucid.utils.validatorToAddress(validator); + const scriptHash = lucid.utils.validatorToScriptHash(validator); + ok(`Validator address: ${validatorAddress}`); + ok(`Script hash: ${scriptHash}`); + + // 6. Check service wallet balance + const serviceAddress = await lucid.wallet().address(); + const utxos = await lucid.utxosAt(serviceAddress); + const totalLovelace = utxos.reduce((acc, u) => acc + (u.assets.lovelace ?? 0n), 0n); + field("Service wallet", serviceAddress); + field("Balance", formatAda(totalLovelace)); + + if (totalLovelace < 5_000_000n) { + warn("Service wallet has less than 5 ADA. Transactions may fail."); + warn(`Fund the address: ${serviceAddress}`); + if (env.network !== "Mainnet") { + warn("Get Preprod tADA: https://docs.cardano.org/cardano-testnet/tools/faucet"); + } + } + + // 7. (Optional) Publish reference script UTxO + let refScriptTxHash: string | null = null; + if (publishRefScript) { + if (totalLovelace < 20_000_000n) { + warn("Insufficient funds to publish reference script (need β‰₯ 20 ADA). Skipping."); + } else { + header("Publishing Reference Script UTxO"); + log("Building reference-script publish transaction…"); + log("(Reference scripts eliminate the need to attach the full script in every collect tx)"); + + // Sending the script to its own address as a reference script reduces + // collect-transaction size by ~4–8 KB, saving ~1–2 ADA per settlement. + const tx = await lucid + .newTx() + .pay.ToAddressWithData( + validatorAddress, + { inline: "d87980" }, // Constr(0,[]) β€” empty inline datum (required) + { lovelace: 15_000_000n }, // 15 ADA minimum for reference scripts + validator, // attach script as reference + ) + .complete(); + + const signed = await tx.sign.withWallet().complete(); + refScriptTxHash = await signed.submit(); + ok(`Reference script UTxO published: ${refScriptTxHash}#0`); + log("Waiting for confirmation (30s)…"); + await sleep(30_000); + } + } + + // 8. Write deployment artefacts + header("Writing Deployment Artefacts"); + + const deployment = { + timestamp: new Date().toISOString(), + network: env.network, + validatorTitle: "payment_gateway.spend", + scriptHash, + validatorAddress, + validatorCbor, + parameters: { + trustedSignerKey: env.trustedSignerKey, + toleranceBps: env.toleranceBps, + }, + referenceScriptUtxo: refScriptTxHash ? `${refScriptTxHash}#0` : null, + plutusVersion: blueprint.preamble.plutusVersion, + }; + + // deployment.json β€” for auditors and PR documentation + const deploymentPath = path.join(ROOT_DIR, "deployment.json"); + fs.writeFileSync(deploymentPath, JSON.stringify(deployment, null, 2)); + ok(`deployment.json written β†’ ${deploymentPath}`); + + // .env.deployed β€” can be sourced by the gateway service + const envDeployedPath = path.join(ROOT_DIR, ".env.deployed"); + const envLines = [ + `# Generated by scripts/deploy.ts on ${new Date().toISOString()}`, + `NETWORK=${env.network}`, + `VALIDATOR_CBOR=${validatorCbor}`, + `VALIDATOR_ADDRESS=${validatorAddress}`, + `SCRIPT_HASH=${scriptHash}`, + `TRUSTED_SIGNER_KEY=${env.trustedSignerKey}`, + `TOLERANCE_BPS=${env.toleranceBps}`, + refScriptTxHash ? `REFERENCE_SCRIPT_UTXO=${refScriptTxHash}#0` : "", + ].filter(Boolean).join("\n"); + fs.writeFileSync(envDeployedPath, envLines + "\n"); + ok(`.env.deployed written β†’ ${envDeployedPath}`); + + // 9. Final summary + header("Deployment Complete"); + field("Script hash", scriptHash); + field("Validator address", validatorAddress); + field("Deployment JSON", deploymentPath); + field("Env file", envDeployedPath); + if (refScriptTxHash) { + field("Ref script UTxO", `${refScriptTxHash}#0`); + } + + console.log("\nNext steps:"); + console.log(" 1. Copy .env.deployed values into your .env file"); + console.log(" 2. Fund the validator address with test ADA (Preprod)"); + console.log(` 3. Verify on explorer: https://preprod.cardanoscan.io/address/${validatorAddress}`); + console.log(" 4. Run: npm run dev to start the gateway service\n"); +} + +main().catch((e) => { + err(String(e)); + process.exit(1); +}); diff --git a/lazer/cardano/integral-payments/scripts/e2e-test.ts b/lazer/cardano/integral-payments/scripts/e2e-test.ts new file mode 100755 index 00000000..f35e4395 --- /dev/null +++ b/lazer/cardano/integral-payments/scripts/e2e-test.ts @@ -0,0 +1,300 @@ +/** + * scripts/e2e-test.ts + * + * IntegralPayments β€” end-to-end integration test (Preprod testnet). + * + * Runs a complete payment lifecycle against the live Preprod network: + * + * Step 1 β€” Verify Blockfrost connectivity and wallet balance. + * Step 2 β€” Fetch a live Pyth ADA/USD price proof. + * Step 3 β€” Build and submit a lock transaction (payment request UTxO). + * Step 4 β€” Wait for the lock UTxO to appear on-chain (up to 120s). + * Step 5 β€” Build and submit a collect transaction (settle the invoice). + * Step 6 β€” Wait for the collect transaction to be confirmed. + * Step 7 β€” Verify the merchant received the correct lovelace amount. + * Step 8 β€” Print a full test report. + * + * This script is used in CI (GitHub Actions) against Preprod before any + * mainnet deployment. It requires real tADA (Cardano testnet ADA). + * + * Usage + * ───── + * npx tsx scripts/e2e-test.ts + * npx tsx scripts/e2e-test.ts --invoice-cents 500 # $5.00 invoice + * npx tsx scripts/e2e-test.ts --skip-collect # lock only (for debugging) + * + * Environment variables required (all) + * ───────────────────────────────────── + * BLOCKFROST_API_KEY Preprod Blockfrost project id + * TRUSTED_SIGNER_KEY 32-byte hex Ed25519 Pyth signer key + * SERVICE_WALLET_SEED BIP-39 mnemonic (holds tADA for fees + deposit) + * MERCHANT_WALLET_ADDRESS Bech32 merchant address (receives the payment) + * NETWORK Must be Preprod for this script + * TOLERANCE_BPS Slippage tolerance (default: 50) + */ + +import { HermesClient } from "@pythnetwork/hermes-client"; +import "dotenv/config"; +import { + abbrev, + applyValidatorParams, + checkBlockfrost, + err, + field, + formatAda, + header, + initLucid, + loadEnv, + log, + ok, + readBlueprint, + sleep, + waitUntil, + warn, +} from "./utils.js"; +import { PaymentService } from "../src/gateway/paymentService.js"; +import { loadConfig } from "../src/config.js"; + +// --------------------------------------------------------------------------- +// CLI argument parsing +// --------------------------------------------------------------------------- + +const invoiceCents = (() => { + const idx = process.argv.indexOf("--invoice-cents"); + return idx !== -1 ? parseInt(process.argv[idx + 1] ?? "1000", 10) : 1000; +})(); +const skipCollect = process.argv.includes("--skip-collect"); + +// --------------------------------------------------------------------------- +// Entry point +// --------------------------------------------------------------------------- + +async function main(): Promise { + const startTime = Date.now(); + + header("IntegralPayments β€” End-to-End Integration Test"); + + const env = loadEnv(); + + if (env.network !== "Preprod") { + err("E2E test must run on Preprod. Set NETWORK=Preprod in .env"); + process.exit(1); + } + + field("Network", env.network); + field("Invoice amount", `$${(invoiceCents / 100).toFixed(2)} (${invoiceCents} cents)`); + field("Tolerance", `${env.toleranceBps} bp`); + field("Skip collect", skipCollect ? "yes" : "no"); + + // ───────────────────────────────────────────────────────────────────────── + // Step 1: Connectivity and wallet balance + // ───────────────────────────────────────────────────────────────────────── + header("Step 1 β€” Connectivity & Wallet Balance"); + + log("Checking Blockfrost…"); + await checkBlockfrost(env.blockfrostUrl, env.blockfrostApiKey); + ok("Blockfrost is healthy."); + + const lucid = await initLucid(env); + const serviceAddr = await lucid.wallet().address(); + const serviceUtxos = await lucid.utxosAt(serviceAddr); + const serviceBalance = serviceUtxos.reduce( + (acc, u) => acc + (u.assets.lovelace ?? 0n), 0n, + ); + + field("Service wallet", abbrev(serviceAddr, 14)); + field("Balance", formatAda(serviceBalance)); + + if (serviceBalance < 10_000_000n) { + err("Service wallet needs at least 10 ADA for this test."); + err(`Fund address: ${serviceAddr}`); + err("Faucet: https://docs.cardano.org/cardano-testnet/tools/faucet"); + process.exit(1); + } + ok("Sufficient balance."); + + // ───────────────────────────────────────────────────────────────────────── + // Step 2: Fetch Pyth price proof + // ───────────────────────────────────────────────────────────────────────── + header("Step 2 β€” Pyth Oracle Price Fetch"); + + const hermesUrl = process.env["PYTH_HERMES_URL"] ?? "https://hermes.pyth.network"; + const hermes = new HermesClient(hermesUrl, { timeout: 10_000 }); + const ADA_FEED = "0x2a01deaec9e51a579277b34b122399984d0bbf57e2458a7e42fecd2829867a0d"; + + log("Fetching ADA/USD price from Hermes…"); + const update = await hermes.getLatestPriceUpdates([ADA_FEED], { + parsed: true, binary: false, + }); + const parsed = update.parsed?.[0]; + if (!parsed) { + err("ADA/USD feed not in Hermes response."); + process.exit(1); + } + + const price = BigInt(parsed.price.price); + const exponent = parsed.price.expo; + const ageS = Math.floor(Date.now() / 1000) - parsed.price.publish_time; + const priceF = Number(price) * Math.pow(10, exponent); + const scale = 10n ** BigInt(-exponent); + const lovelaceNeeded = (BigInt(invoiceCents) * 1_000_000n * scale) / (price * 100n); + + field("ADA/USD price", `$${priceF.toFixed(8)}`); + field("Price age", `${ageS}s`); + field("Required ADA", formatAda(lovelaceNeeded)); + + if (ageS > 60) { + warn(`Price is ${ageS}s old β€” close to the freshness limit.`); + } + ok("Price proof fetched."); + + // ───────────────────────────────────────────────────────────────────────── + // Step 3: Lock transaction + // ───────────────────────────────────────────────────────────────────────── + header("Step 3 β€” Lock Transaction"); + + const config = loadConfig(); + const service = await PaymentService.create(config, env.serviceSeed); + + const invoiceRef = `E2E-TEST-${Date.now()}`; + log(`Creating payment request for invoice: ${invoiceRef}`); + + const { request, lockResult } = await service.createPaymentRequest( + invoiceRef, + env.merchantAddress, + invoiceCents, + "ADA/USD", + serviceAddr, // use service wallet as customer for the E2E test + ); + + ok(`Lock tx submitted: ${lockResult.txHash}`); + ok(`UTxO ref: ${lockResult.utxoRef}`); + field("Payment ID", request.datum.paymentId); + + if (skipCollect) { + warn("--skip-collect: stopping after lock transaction."); + printSummary(startTime, { lockTxHash: lockResult.txHash }); + return; + } + + // ───────────────────────────────────────────────────────────────────────── + // Step 4: Wait for lock UTxO to appear on-chain + // ───────────────────────────────────────────────────────────────────────── + header("Step 4 β€” Waiting for Lock UTxO Confirmation"); + + log("Polling for UTxO at validator address (up to 120s)…"); + const blueprint = readBlueprint(); + const validator = applyValidatorParams(blueprint, env.trustedSignerKey, env.toleranceBps); + const validatorAddr = lucid.utils.validatorToAddress(validator); + + const lockConfirmed = await waitUntil(async () => { + const utxos = await lucid.utxosAt(validatorAddr); + return utxos.some((u) => u.txHash === lockResult.txHash.split("#")[0]); + }, 120_000, 6_000); + + if (!lockConfirmed) { + err("Lock UTxO did not appear within 120s β€” check Blockfrost/network."); + process.exit(1); + } + ok("Lock UTxO confirmed on-chain."); + + // ───────────────────────────────────────────────────────────────────────── + // Step 5: Collect transaction (settle invoice) + // ───────────────────────────────────────────────────────────────────────── + header("Step 5 β€” Collect Transaction"); + + log("Submitting collect (settle) transaction…"); + const collectResult = await service.settlePayment( + request.datum.paymentId, + env.serviceSeed, // service wallet acts as customer in the E2E test + ); + + ok(`Collect tx submitted: ${collectResult.txHash}`); + field("Paid lovelace", formatAda(collectResult.paidLovelace)); + field("Price used", `$${collectResult.priceUsed.priceFloat.toFixed(8)}`); + + // ───────────────────────────────────────────────────────────────────────── + // Step 6: Wait for collect confirmation + // ───────────────────────────────────────────────────────────────────────── + header("Step 6 β€” Waiting for Collect Confirmation"); + + log("Polling for collect tx (up to 120s)…"); + const collectConfirmed = await waitUntil(async () => { + // UTxO should disappear from validator once collected + const utxos = await lucid.utxosAt(validatorAddr); + return !utxos.some((u) => u.txHash === lockResult.txHash.split("#")[0]); + }, 120_000, 6_000); + + if (!collectConfirmed) { + warn("Could not confirm collect tx within 120s β€” check manually."); + } else { + ok("Collect confirmed: UTxO no longer at validator."); + service.confirmSettlement(request.datum.paymentId); + } + + // ───────────────────────────────────────────────────────────────────────── + // Step 7: Verify merchant received payment + // ───────────────────────────────────────────────────────────────────────── + header("Step 7 β€” Merchant Balance Verification"); + + log("Querying merchant address for recent UTxOs…"); + const merchantUtxos = await lucid.utxosAt(env.merchantAddress); + const merchantTotal = merchantUtxos.reduce( + (acc, u) => acc + (u.assets.lovelace ?? 0n), 0n, + ); + field("Merchant address", abbrev(env.merchantAddress, 14)); + field("Merchant balance", formatAda(merchantTotal)); + + // Check the expected amount landed (within tolerance) + const toleranceFactor = (10_000n - BigInt(env.toleranceBps)) * lovelaceNeeded / 10_000n; + const recentMerchantUtxo = merchantUtxos.find( + (u) => (u.assets.lovelace ?? 0n) >= toleranceFactor, + ); + if (recentMerchantUtxo) { + ok(`Merchant UTxO found with β‰₯ ${formatAda(toleranceFactor)}.`); + } else { + warn("Could not find a qualifying merchant UTxO β€” may need manual verification."); + } + + // ───────────────────────────────────────────────────────────────────────── + // Final report + // ───────────────────────────────────────────────────────────────────────── + printSummary(startTime, { + lockTxHash: lockResult.txHash, + collectTxHash: collectResult.txHash, + paidLovelace: collectResult.paidLovelace, + invoiceCents, + }); +} + +function printSummary( + startTime: number, + data: { + lockTxHash: string; + collectTxHash?: string; + paidLovelace?: bigint; + invoiceCents?: number; + }, +): void { + const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); + + header("Test Summary"); + field("Duration", `${elapsed}s`); + field("Lock tx", data.lockTxHash); + if (data.collectTxHash) { + field("Collect tx", data.collectTxHash); + field("Paid", formatAda(data.paidLovelace ?? 0n)); + field("Invoice", `$${((data.invoiceCents ?? 0) / 100).toFixed(2)}`); + console.log(`\n Explorer (lock): https://preprod.cardanoscan.io/transaction/${data.lockTxHash}`); + console.log(` Explorer (collect): https://preprod.cardanoscan.io/transaction/${data.collectTxHash}\n`); + ok("E2E test PASSED."); + } else { + console.log(`\n Explorer: https://preprod.cardanoscan.io/transaction/${data.lockTxHash}\n`); + ok("Lock transaction submitted. Collect skipped."); + } +} + +main().catch((e) => { + err(`E2E test FAILED: ${String(e)}`); + process.exit(1); +}); diff --git a/lazer/cardano/integral-payments/scripts/generate-wallet.ts b/lazer/cardano/integral-payments/scripts/generate-wallet.ts new file mode 100755 index 00000000..edf36c95 --- /dev/null +++ b/lazer/cardano/integral-payments/scripts/generate-wallet.ts @@ -0,0 +1,160 @@ +/** + * scripts/generate-wallet.ts + * + * IntegralPayments β€” service wallet generator. + * + * Generates a new BIP-39 mnemonic seed phrase and derives the corresponding + * Cardano addresses for Mainnet, Preprod, and Preview networks. + * + * The generated seed phrase is used as SERVICE_WALLET_SEED in the gateway's + * .env file. This wallet: + * - Pays transaction fees for lock transactions. + * - Creates the payment-request UTxOs at the validator address. + * - Does NOT hold merchant funds β€” those flow directly to merchant_address. + * + * SECURITY NOTICE + * ─────────────── + * This script prints the seed phrase to stdout in plaintext. + * In production: + * - Run this offline on an air-gapped machine. + * - Store the seed phrase in a secrets manager (e.g. HashiCorp Vault, + * AWS Secrets Manager) β€” never commit it to version control. + * - Fund the service wallet with a small ADA float (5–20 ADA) to cover + * transaction fees; it should never hold significant value. + * + * Usage + * ───── + * npx tsx scripts/generate-wallet.ts + * npx tsx scripts/generate-wallet.ts --save # append to .env.wallet (gitignored) + */ + +import fs from "node:fs"; +import path from "node:path"; +import { + Lucid, + Blockfrost, + generateSeedPhrase, +} from "@lucid-evolution/lucid"; +import "dotenv/config"; +import { + err, + field, + header, + log, + ok, + ROOT_DIR, + warn, +} from "./utils.js"; + +// --------------------------------------------------------------------------- +// Entry point +// --------------------------------------------------------------------------- + +async function main(): Promise { + const saveToFile = process.argv.includes("--save"); + + header("IntegralPayments β€” Service Wallet Generator"); + + warn("SECURITY: Keep the seed phrase secret. Never commit it to git."); + warn("SECURITY: This script outputs plaintext β€” run in a secure environment.\n"); + + // 1. Generate seed phrase + const seedPhrase = generateSeedPhrase(); + ok("BIP-39 seed phrase generated (24 words)."); + + // 2. Derive addresses for all networks using a minimal Lucid instance. + // We use a dummy Blockfrost provider just to initialise Lucid; no + // network queries are made during address derivation. + const networks: Array<{ name: string; network: "Mainnet" | "Preprod" | "Preview" }> = [ + { name: "Mainnet", network: "Mainnet" }, + { name: "Preprod", network: "Preprod" }, + { name: "Preview", network: "Preview" }, + ]; + + const derivedAddresses: Record = {}; + + log("Deriving addresses for all networks…"); + for (const { name, network } of networks) { + // For address derivation we don't make any real Blockfrost calls, + // so the API key value doesn't matter here. + const lucid = await Lucid( + new Blockfrost( + `https://cardano-${name.toLowerCase()}.blockfrost.io/api/v0`, + "dummy_key_for_address_derivation", + ), + network, + ); + lucid.selectWallet.fromSeed(seedPhrase); + const address = await lucid.wallet().address(); + derivedAddresses[name] = address; + field(`${name} address`, address); + } + + // 3. Extract the payment key hash (same for all networks) + const lucidAny = await Lucid( + new Blockfrost( + "https://cardano-preprod.blockfrost.io/api/v0", + "dummy_key_for_address_derivation", + ), + "Preprod", + ); + lucidAny.selectWallet.fromSeed(seedPhrase); + const preprodDetails = lucidAny.utils.getAddressDetails( + derivedAddresses["Preprod"]!, + ); + const paymentKeyHash = preprodDetails.paymentCredential?.hash ?? "unknown"; + field("Payment key hash", paymentKeyHash); + + // 4. Print the seed phrase + header("Seed Phrase β€” KEEP SECRET"); + console.log(`\n ${seedPhrase}\n`); + warn("Write this down and store it securely. It cannot be recovered."); + + // 5. Print .env snippet + header(".env Configuration Snippet"); + const envSnippet = [ + `# IntegralPayments service wallet β€” generated ${new Date().toISOString()}`, + `SERVICE_WALLET_SEED="${seedPhrase}"`, + `# Preprod address: ${derivedAddresses["Preprod"]}`, + `# Mainnet address: ${derivedAddresses["Mainnet"]}`, + ``, + `# Fund the Preprod address:`, + `# https://docs.cardano.org/cardano-testnet/tools/faucet`, + `# (select "Preprod" and paste the address above)`, + ].join("\n"); + + console.log(envSnippet); + + // 6. Optionally save to .env.wallet + if (saveToFile) { + const walletEnvPath = path.join(ROOT_DIR, ".env.wallet"); + fs.writeFileSync(walletEnvPath, envSnippet + "\n"); + ok(`\nSaved to: ${walletEnvPath}`); + warn("Make sure .env.wallet is in .gitignore β€” it contains your seed phrase!"); + + // Verify it's gitignored + const gitignorePath = path.join(ROOT_DIR, ".gitignore"); + if (fs.existsSync(gitignorePath)) { + const gitignore = fs.readFileSync(gitignorePath, "utf8"); + if (!gitignore.includes(".env.wallet")) { + warn(".env.wallet is NOT in .gitignore β€” add it immediately!"); + } + } + } + + // 7. Funding instructions + header("Next Steps"); + console.log(" 1. Copy SERVICE_WALLET_SEED into your .env file"); + console.log(" 2. Fund the Preprod address with test ADA:"); + console.log(` https://docs.cardano.org/cardano-testnet/tools/faucet`); + console.log(` Address: ${derivedAddresses["Preprod"]}`); + console.log(" 3. Verify balance:"); + console.log(` https://preprod.cardanoscan.io/address/${derivedAddresses["Preprod"]}`); + console.log(" 4. Run deploy script once funded:"); + console.log(" npx tsx scripts/deploy.ts\n"); +} + +main().catch((e) => { + err(String(e)); + process.exit(1); +}); diff --git a/lazer/cardano/integral-payments/scripts/utils.ts b/lazer/cardano/integral-payments/scripts/utils.ts new file mode 100755 index 00000000..13abd4bf --- /dev/null +++ b/lazer/cardano/integral-payments/scripts/utils.ts @@ -0,0 +1,261 @@ +/** + * scripts/utils.ts + * + * Shared utilities for all IntegralPayments deployment and utility scripts. + * + * Provides: + * - Environment loading and validation + * - Lucid Evolution initialisation + * - plutus.json blueprint reading and CBOR extraction + * - Coloured console output helpers + * - Blockfrost health-check with retry + */ + +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + Blockfrost, + Lucid, + applyParamsToScript, + Data, + Constr, + type SpendingValidator, + type Network, +} from "@lucid-evolution/lucid"; +import "dotenv/config"; + +// --------------------------------------------------------------------------- +// Directory resolution (ESM-safe) +// --------------------------------------------------------------------------- + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +/** Absolute path to the repository root (one level above scripts/) */ +export const ROOT_DIR = path.resolve(__dirname, ".."); + +/** Absolute path to contracts/plutus.json */ +export const BLUEPRINT_PATH = path.join(ROOT_DIR, "contracts", "plutus.json"); + +// --------------------------------------------------------------------------- +// Console helpers +// --------------------------------------------------------------------------- + +const C = { + reset: "\x1b[0m", + bold: "\x1b[1m", + green: "\x1b[32m", + yellow: "\x1b[33m", + red: "\x1b[31m", + cyan: "\x1b[36m", + gray: "\x1b[90m", +}; + +export function log(msg: string) { console.log(`${C.gray}[ip]${C.reset} ${msg}`); } +export function ok(msg: string) { console.log(`${C.green}βœ“${C.reset} ${msg}`); } +export function warn(msg: string) { console.warn(`${C.yellow}⚠${C.reset} ${msg}`); } +export function err(msg: string) { console.error(`${C.red}βœ—${C.reset} ${msg}`); } +export function header(msg: string) { console.log(`\n${C.bold}${C.cyan}${msg}${C.reset}\n${"─".repeat(60)}`); } +export function field(k: string, v: string) { + console.log(` ${C.gray}${k.padEnd(22)}${C.reset}${v}`); +} + +// --------------------------------------------------------------------------- +// Environment helpers +// --------------------------------------------------------------------------- + +function required(key: string): string { + const val = process.env[key]; + if (!val) { + err(`Missing required environment variable: ${C.bold}${key}${C.reset}`); + process.exit(1); + } + return val; +} + +function optional(key: string, fallback: string): string { + return process.env[key] ?? fallback; +} + +export type ScriptEnv = { + network: Network; + blockfrostApiKey: string; + blockfrostUrl: string; + trustedSignerKey: string; + toleranceBps: number; + merchantAddress: string; + serviceSeed: string; +}; + +/** Load and validate all environment variables needed by the scripts. */ +export function loadEnv(): ScriptEnv { + const network = optional("NETWORK", "Preprod") as Network; + if (!["Mainnet", "Preprod", "Preview"].includes(network)) { + err(`NETWORK must be Mainnet | Preprod | Preview, got: ${network}`); + process.exit(1); + } + + const BLOCKFROST_URLS: Record = { + Mainnet: "https://cardano-mainnet.blockfrost.io/api/v0", + Preprod: "https://cardano-preprod.blockfrost.io/api/v0", + Preview: "https://cardano-preview.blockfrost.io/api/v0", + }; + + return { + network, + blockfrostApiKey: required("BLOCKFROST_API_KEY"), + blockfrostUrl: optional("BLOCKFROST_URL", BLOCKFROST_URLS[network]!), + trustedSignerKey: required("TRUSTED_SIGNER_KEY"), + toleranceBps: parseInt(optional("TOLERANCE_BPS", "50"), 10), + merchantAddress: required("MERCHANT_WALLET_ADDRESS"), + serviceSeed: required("SERVICE_WALLET_SEED"), + }; +} + +// --------------------------------------------------------------------------- +// Lucid initialisation +// --------------------------------------------------------------------------- + +/** Create a Lucid instance connected to Blockfrost and select the service wallet. */ +export async function initLucid(env: ScriptEnv): Promise> { + const provider = new Blockfrost(env.blockfrostUrl, env.blockfrostApiKey); + const lucid = await Lucid(provider, env.network); + lucid.selectWallet.fromSeed(env.serviceSeed); + return lucid; +} + +// --------------------------------------------------------------------------- +// Blueprint / validator helpers +// --------------------------------------------------------------------------- + +export type Blueprint = { + preamble: { version: string; plutusVersion: string }; + validators: Array<{ + title: string; + datum?: { schema: unknown }; + redeemer: { schema: unknown }; + parameters?: Array<{ title: string; schema: unknown }>; + compiledCode: string; + hash: string; + }>; +}; + +/** Read and parse contracts/plutus.json produced by `aiken build`. */ +export function readBlueprint(): Blueprint { + if (!fs.existsSync(BLUEPRINT_PATH)) { + err(`plutus.json not found at: ${BLUEPRINT_PATH}`); + err(`Run cd contracts && aiken build first.`); + process.exit(1); + } + return JSON.parse(fs.readFileSync(BLUEPRINT_PATH, "utf8")) as Blueprint; +} + +/** + * Find the `payment_gateway.spend` validator entry in the blueprint, + * apply the two runtime parameters (trustedSignerKey, toleranceBps), + * and return the finalised `SpendingValidator` ready for Lucid. + * + * Parameter encoding mirrors the on-chain Aiken validator signature: + * validator payment_gateway(trusted_signer: ByteArray, tolerance_bps: Int) + * + * @param blueprint Parsed plutus.json + * @param trustedSigner 32-byte Ed25519 signer key (hex, no 0x) + * @param toleranceBps Slippage tolerance in basis points + */ +export function applyValidatorParams( + blueprint: Blueprint, + trustedSigner: string, + toleranceBps: number, +): SpendingValidator { + const entry = blueprint.validators.find((v) => + v.title === "payment_gateway.spend", + ); + if (!entry) { + err(`Validator "payment_gateway.spend" not found in plutus.json.`); + err(`Available validators: ${blueprint.validators.map((v) => v.title).join(", ")}`); + process.exit(1); + } + + // Apply parameters in declaration order using applyParamsToScript. + // Aiken ByteArray parameters are passed as hex strings. + // Aiken Int parameters are passed as BigInt. + const parameterisedCbor = applyParamsToScript( + entry.compiledCode, + [trustedSigner, BigInt(toleranceBps)], + ); + + return { type: "PlutusV3", script: parameterisedCbor }; +} + +// --------------------------------------------------------------------------- +// Blockfrost health-check +// --------------------------------------------------------------------------- + +/** + * Verify the Blockfrost API key is valid and the node is reachable. + * Retries up to `maxRetries` times with exponential back-off. + */ +export async function checkBlockfrost( + url: string, + apiKey: string, + maxRetries = 3, +): Promise { + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + const res = await fetch(`${url}/health`, { + headers: { project_id: apiKey }, + }); + if (!res.ok) { + throw new Error(`HTTP ${res.status} ${res.statusText}`); + } + const body = await res.json() as { is_healthy: boolean }; + if (!body.is_healthy) throw new Error("Blockfrost reports unhealthy"); + return; // success + } catch (e) { + if (attempt === maxRetries) { + err(`Blockfrost health-check failed after ${maxRetries} attempts: ${String(e)}`); + process.exit(1); + } + const delay = 1000 * 2 ** attempt; + warn(`Blockfrost unreachable (attempt ${attempt}/${maxRetries}). Retrying in ${delay / 1000}s…`); + await sleep(delay); + } + } +} + +// --------------------------------------------------------------------------- +// Misc +// --------------------------------------------------------------------------- + +export const sleep = (ms: number) => + new Promise((resolve) => setTimeout(resolve, ms)); + +/** + * Poll until `condition()` returns true or `timeoutMs` elapses. + * `intervalMs` controls the polling frequency. + */ +export async function waitUntil( + condition: () => Promise, + timeoutMs = 120_000, + intervalMs = 5_000, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (await condition()) return true; + await sleep(intervalMs); + } + return false; +} + +/** Format lovelace as a human-readable ADA string with 6 decimal places. */ +export function formatAda(lovelace: bigint): string { + const ada = Number(lovelace) / 1_000_000; + return `${ada.toFixed(6)} ADA (${lovelace.toLocaleString()} lovelace)`; +} + +/** Abbreviate a long hex string for display: first 8 + … + last 8 chars. */ +export function abbrev(hex: string, len = 8): string { + if (hex.length <= len * 2 + 3) return hex; + return `${hex.slice(0, len)}…${hex.slice(-len)}`; +} diff --git a/lazer/cardano/integral-payments/scripts/verify-pyth-feeds.ts b/lazer/cardano/integral-payments/scripts/verify-pyth-feeds.ts new file mode 100755 index 00000000..0e5133ba --- /dev/null +++ b/lazer/cardano/integral-payments/scripts/verify-pyth-feeds.ts @@ -0,0 +1,250 @@ +/** + * scripts/verify-pyth-feeds.ts + * + * IntegralPayments β€” Pyth oracle feed verification utility. + * + * Fetches the latest signed price update for every supported feed from the + * Pyth Hermes API and runs the same validation checks the gateway service + * performs before building a collect transaction: + * + * - Freshness : publish_time is within maxPriceAgeSeconds + * - Confidence : conf/price ratio is within 10 % + * - Signer key : proof.signerKey matches TRUSTED_SIGNER_KEY + * - Lovelace : compute ADA equivalent for a $10 test invoice + * + * Exits with code 0 if all feeds pass, code 1 if any fail. + * + * Usage + * ───── + * npx tsx scripts/verify-pyth-feeds.ts + * npx tsx scripts/verify-pyth-feeds.ts --feed ADA/USD # single feed + * npx tsx scripts/verify-pyth-feeds.ts --watch # loop every 30s + * + * Environment variables required + * ─────────────────────────────── + * TRUSTED_SIGNER_KEY 32-byte hex Ed25519 Pyth signer key + * PYTH_HERMES_URL Hermes endpoint (default: https://hermes.pyth.network) + * MAX_PRICE_AGE_SECONDS (default: 60) + */ + +import { HermesClient } from "@pythnetwork/hermes-client"; +import "dotenv/config"; +import { + err, + field, + header, + log, + ok, + sleep, + warn, +} from "./utils.js"; + +// --------------------------------------------------------------------------- +// Feed registry +// --------------------------------------------------------------------------- + +const FEEDS: Record = { + "ADA/USD": + "0x2a01deaec9e51a579277b34b122399984d0bbf57e2458a7e42fecd2829867a0d", + "BTC/USD": + "0xe62df6c8b4a85fe1a67db44dc12de5db330f7ac66b72dc658afedf0f4a415b43", + "ETH/USD": + "0xff61491a931112ddf1bd8147cd1b641375f79f5825126d665480874634fd0ace", + "USDC/USD": + "0xeaa020c61cc479712813461ce153894a96a6c00b21ed0cfc2798d1f9a9e9c94a", +}; + +// --------------------------------------------------------------------------- +// Validation helpers +// --------------------------------------------------------------------------- + +interface FeedResult { + name: string; + feedId: string; + priceFloat: number; + confPct: number; + ageSeconds: number; + publishTime: number; + lovelace10usd: bigint; + passed: boolean; + failures: string[]; +} + +function validate( + name: string, + feedId: string, + parsed: { + price: { price: string; conf: string; expo: number; publish_time: number }; + }, + trustedSignerKey: string, + maxAgeSeconds: number, +): FeedResult { + const price = BigInt(parsed.price.price); + const conf = BigInt(parsed.price.conf); + const exponent = parsed.price.expo; + const publishTime = parsed.price.publish_time; + + const priceFloat = Number(price) * Math.pow(10, exponent); + const confFloat = Number(conf) * Math.pow(10, exponent); + const confPct = (confFloat / priceFloat) * 100; + const now = Math.floor(Date.now() / 1000); + const ageSeconds = now - publishTime; + + const failures: string[] = []; + + // 1. Freshness + if (ageSeconds < 0) { + failures.push(`Timestamp is in the future by ${-ageSeconds}s (clock skew?)`); + } else if (ageSeconds > maxAgeSeconds) { + failures.push(`Stale: ${ageSeconds}s old (max ${maxAgeSeconds}s)`); + } + + // 2. Confidence + if (conf * 10n > price) { + failures.push(`Confidence too wide: ${confPct.toFixed(2)}% (max 10%)`); + } + + // 3. Lovelace computation for a $10.00 test invoice + // lovelace = 1000 * 1_000_000 * 10^(-expo) / (price * 100) + const scale = 10n ** BigInt(-exponent); + const lovelace10usd = (1000n * 1_000_000n * scale) / (price * 100n); + + return { + name, + feedId: feedId.replace("0x", ""), + priceFloat, + confPct, + ageSeconds, + publishTime, + lovelace10usd, + passed: failures.length === 0, + failures, + }; +} + +// --------------------------------------------------------------------------- +// Single check pass +// --------------------------------------------------------------------------- + +async function runCheck( + targetFeed: string | null, + trustedSignerKey: string, + hermesUrl: string, + maxAgeSeconds: number, +): Promise { + const hermes = new HermesClient(hermesUrl, { timeout: 10_000 }); + + const feedsToCheck = targetFeed + ? Object.entries(FEEDS).filter(([name]) => name === targetFeed) + : Object.entries(FEEDS); + + if (feedsToCheck.length === 0) { + err(`Unknown feed: ${targetFeed}. Valid options: ${Object.keys(FEEDS).join(", ")}`); + return false; + } + + const ids = feedsToCheck.map(([, id]) => id); + + log(`Fetching ${feedsToCheck.length} feed(s) from ${hermesUrl}…`); + let update: Awaited>; + try { + update = await hermes.getLatestPriceUpdates(ids, { parsed: true, binary: false }); + } catch (e) { + err(`Hermes request failed: ${String(e)}`); + return false; + } + + const results: FeedResult[] = []; + + for (const [name, feedId] of feedsToCheck) { + const cleanId = feedId.replace("0x", ""); + const parsed = update.parsed?.find( + (p) => p.id.replace("0x", "") === cleanId, + ); + if (!parsed) { + err(`Feed ${name} not found in Hermes response.`); + results.push({ + name, feedId: cleanId, priceFloat: 0, confPct: 0, + ageSeconds: 0, publishTime: 0, lovelace10usd: 0n, + passed: false, + failures: ["Not present in Hermes response"], + }); + continue; + } + results.push(validate(name, feedId, parsed, trustedSignerKey, maxAgeSeconds)); + } + + // Print results + let allPassed = true; + for (const r of results) { + header(`${r.name} ${r.passed ? "βœ“ PASS" : "βœ— FAIL"}`); + field("Feed ID", r.feedId.slice(0, 16) + "…"); + field("Price", `$${r.priceFloat.toFixed(8)}`); + field("Confidence", `Β±$${(r.priceFloat * r.confPct / 100).toFixed(8)} (${r.confPct.toFixed(3)}%)`); + field("Age", `${r.ageSeconds}s`); + field("Publish time", new Date(r.publishTime * 1000).toISOString()); + field("$10 invoice", `${r.lovelace10usd.toLocaleString()} lovelace`); + field(" ", `(${(Number(r.lovelace10usd) / 1_000_000).toFixed(6)} ADA)`); + + if (r.passed) { + ok("All validation checks passed."); + } else { + allPassed = false; + for (const f of r.failures) { + warn(`FAIL: ${f}`); + } + } + } + + return allPassed; +} + +// --------------------------------------------------------------------------- +// Entry point +// --------------------------------------------------------------------------- + +async function main(): Promise { + header("IntegralPayments β€” Pyth Feed Verification"); + + const targetFeed = (() => { + const idx = process.argv.indexOf("--feed"); + return idx !== -1 ? process.argv[idx + 1] ?? null : null; + })(); + const watchMode = process.argv.includes("--watch"); + const watchInterval = 30_000; + + const trustedSignerKey = process.env["TRUSTED_SIGNER_KEY"] ?? ""; + if (!trustedSignerKey) { + err("TRUSTED_SIGNER_KEY is not set. Cannot verify signer key field."); + } + + const hermesUrl = process.env["PYTH_HERMES_URL"] ?? "https://hermes.pyth.network"; + const maxAgeSeconds = parseInt(process.env["MAX_PRICE_AGE_SECONDS"] ?? "60", 10); + + field("Hermes URL", hermesUrl); + field("Max price age", `${maxAgeSeconds}s`); + field("Target feed", targetFeed ?? "all"); + field("Watch mode", watchMode ? `yes (every ${watchInterval / 1000}s)` : "no"); + + if (watchMode) { + log("Entering watch mode. Press Ctrl-C to stop.\n"); + while (true) { + const passed = await runCheck(targetFeed, trustedSignerKey, hermesUrl, maxAgeSeconds); + if (!passed) warn("One or more feeds failed validation."); + log(`Next check in ${watchInterval / 1000}s…`); + await sleep(watchInterval); + } + } else { + const passed = await runCheck(targetFeed, trustedSignerKey, hermesUrl, maxAgeSeconds); + if (!passed) { + err("One or more feeds failed validation."); + process.exit(1); + } + ok("All feed checks passed."); + } +} + +main().catch((e) => { + err(String(e)); + process.exit(1); +}); diff --git a/lazer/cardano/integral-payments/src/cardano/transaction.ts b/lazer/cardano/integral-payments/src/cardano/transaction.ts new file mode 100755 index 00000000..265c58e6 --- /dev/null +++ b/lazer/cardano/integral-payments/src/cardano/transaction.ts @@ -0,0 +1,389 @@ +/** + * src/cardano/transaction.ts + * + * Cardano transaction builder for IntegralPayments. + * + * Exposes two public functions: + * - `buildLockTx` β€” creates the payment-request UTxO at the validator + * - `buildCollectTx` β€” settles an invoice by spending the validator UTxO + * + * Both functions return a signed `TxSigned` object ready to be submitted + * to the Cardano network. Submission is handled by `paymentService.ts`. + * + * This module is intentionally stateless: it receives all required context + * (lucid instance, validator, UTxO references, price proof) as parameters + * and produces a deterministic transaction for any given input set. + * + * Dependencies: + * @lucid-evolution/lucid v0.4.x + */ + +import { + type Lucid, + type SpendingValidator, + type UTxO, + Data, + Constr, + toHex, + fromHex, +} from "@lucid-evolution/lucid"; +import type { + CollectResult, + GatewayConfig, + LockResult, + PaymentDatum, + PaymentRedeemer, + ResolvedPrice, +} from "../types.js"; +import { PythClient } from "../oracle/pythClient.js"; + +// --------------------------------------------------------------------------- +// Plutus Data schemas +// Mirrors the on-chain Aiken types using Lucid's `Data` encoding. +// +// Cardano/Plutus Data encoding rules for Aiken records: +// - A record type is encoded as Constr(0, [...fields in declaration order]) +// - ByteArray -> hex string in Lucid +// - Int -> BigInt in Lucid +// - Bool -> Constr(1,[]) for True, Constr(0,[]) for False +// - Address -> Constr(0, [Constr(0,[pkh]), Constr(1,[Constr(0,[Constr(0,[pkh])])])]) for +// a base address with staking credential, or simplified for enterprise +// --------------------------------------------------------------------------- + +/** + * Encode a `PaymentDatum` into Plutus Data (Constr 0). + * + * Field order must exactly match the Aiken type declaration: + * payment_id, merchant_address, invoice_usd_cents, + * accepted_feed_id, customer_pkh, created_at + */ +function encodeDatum(datum: PaymentDatum): string { + // Encode merchant address as a Cardano payment credential. + // For an enterprise address (no staking), the Plutus encoding is: + // Address { payment_credential: VerificationKey(pkh), staking_credential: None } + // = Constr(0, [Constr(0, [pkh_bytes]), Constr(1, [])]) β€” None = Constr(1,[]) + const merchantPkh = addressToPkh(datum.merchantAddress); + const merchantAddressData = new Constr(0, [ + new Constr(0, [merchantPkh]), // VerificationKey credential + new Constr(1, []), // staking_credential = None + ]); + + return Data.to( + new Constr(0, [ + datum.paymentId, // ByteArray + merchantAddressData, // Address + BigInt(datum.invoiceUsdCents),// Int + datum.acceptedFeedId, // ByteArray + datum.customerPkh, // VerificationKeyHash (ByteArray) + BigInt(datum.createdAt), // Int + ]), + ); +} + +/** + * Encode a `PaymentRedeemer` into Plutus Data (Constr 0). + * + * Field order must exactly match the Aiken type: + * proof: PriceProof, now_seconds: Int + * + * PriceProof field order: + * feed_id, price, conf, exponent, publish_time, signature, signer_key + */ +function encodeRedeemer(redeemer: PaymentRedeemer): string { + const p = redeemer.proof; + + const proofData = new Constr(0, [ + p.feedId, // ByteArray + p.price, // Int (bigint) + p.conf, // Int (bigint) + BigInt(p.exponent), // Int + BigInt(p.publishTime), // Int + p.signature, // ByteArray + p.signerKey, // ByteArray + ]); + + return Data.to( + new Constr(0, [ + proofData, // PriceProof + BigInt(redeemer.nowSeconds), // Int + ]), + ); +} + +// --------------------------------------------------------------------------- +// Address utilities +// --------------------------------------------------------------------------- + +/** + * Extract the payment key hash (hex) from a bech32 Cardano address. + * Lucid provides `lucid.utils.getAddressDetails` for this purpose. + */ +function addressToPkh(bech32Address: string): string { + // This function is called from the transaction builder where `lucid` is + // available. The actual extraction is done inline in buildLockTx using + // lucid.utils.getAddressDetails so we can access the Lucid instance. + // This stub is here for documentation purposes. + throw new Error( + "addressToPkh must be called via lucid.utils.getAddressDetails", + ); +} + +/** Encode address using the Lucid instance for proper network-aware parsing. */ +function encodeAddress(lucid: Lucid, bech32Address: string): Constr { + const details = lucid.utils.getAddressDetails(bech32Address); + const pkh = details.paymentCredential?.hash ?? ""; + + const paymentCred = + details.paymentCredential?.type === "Script" + ? new Constr(1, [pkh]) // Script credential + : new Constr(0, [pkh]); // VerificationKey credential + + const stakingCred = + details.stakeCredential + ? new Constr(0, [ + new Constr(0, [ + new Constr(0, [details.stakeCredential.hash]), + ]), + ]) + : new Constr(1, []); // None + + return new Constr(0, [paymentCred, stakingCred]); +} + +/** Re-implementation of encodeDatum that uses the Lucid instance for address encoding. */ +function encodeDatumWithLucid(lucid: Lucid, datum: PaymentDatum): string { + const merchantAddressData = encodeAddress(lucid, datum.merchantAddress); + + return Data.to( + new Constr(0, [ + datum.paymentId, + merchantAddressData, + BigInt(datum.invoiceUsdCents), + datum.acceptedFeedId, + datum.customerPkh, + BigInt(datum.createdAt), + ]), + ); +} + +// --------------------------------------------------------------------------- +// Transaction builder +// --------------------------------------------------------------------------- + +export class CardanoTransactionBuilder { + private readonly lucid: Lucid; + private readonly validator: SpendingValidator; + private readonly validatorAddress: string; + private readonly config: GatewayConfig; + private readonly pythClient: PythClient; + + constructor( + lucid: Lucid, + config: GatewayConfig, + pythClient: PythClient, + ) { + this.lucid = lucid; + this.config = config; + this.pythClient = pythClient; + + this.validator = { + type: "PlutusV3", + script: config.validatorCbor, + }; + this.validatorAddress = + lucid.utils.validatorToAddress(this.validator); + } + + /** Expose the computed validator address for use by other modules. */ + getValidatorAddress(): string { + return this.validatorAddress; + } + + // ------------------------------------------------------------------------- + // Lock transaction + // ------------------------------------------------------------------------- + + /** + * Build, sign, and submit a "lock" transaction that creates a payment- + * request UTxO at the validator address. + * + * The UTxO carries: + * - An inline `PaymentDatum` recording the invoice details. + * - `config.minDepositLovelace` ADA to satisfy the minimum UTxO requirement. + * + * The merchant's service wallet signs and submits this transaction on behalf + * of the invoice creation event from the ERP module. + * + * @param datum Full payment datum for this invoice + * @returns LockResult containing the txHash and UTxO reference + */ + async buildLockTx(datum: PaymentDatum): Promise { + const encodedDatum = encodeDatumWithLucid(this.lucid, datum); + + const tx = await this.lucid + .newTx() + .pay.ToContract( + this.validatorAddress, + { inline: encodedDatum }, + { lovelace: this.config.minDepositLovelace }, + ) + .complete(); + + const signed = await tx.sign.withWallet().complete(); + const txHash = await signed.submit(); + + // The new UTxO will be at index 0 of the transaction outputs + // (the lock output is the first explicit pay.ToContract call). + // In production, confirm this by querying the UTxO set after submission. + const utxoRef = `${txHash}#0`; + + console.log(`[CardanoTx] Lock tx submitted: ${txHash}`); + return { txHash, utxoRef }; + } + + // ------------------------------------------------------------------------- + // Collect transaction + // ------------------------------------------------------------------------- + + /** + * Build, sign, and submit a "collect" (settle) transaction that: + * 1. Fetches the latest Pyth price for the invoice's accepted feed. + * 2. Computes the exact lovelace amount required to cover the invoice. + * 3. Spends the locked UTxO by providing the price proof as a redeemer. + * 4. Sends the computed lovelace to the merchant's address. + * 5. Returns the deposit UTxO lovelace back to the customer as change + * (Lucid handles change output automatically). + * + * The customer's wallet MUST be selected on the Lucid instance before + * calling this function. The transaction requires the customer's signature + * as an extra signatory (enforced by the on-chain validator). + * + * @param lockedUtxo The UTxO locked at the validator (from buildLockTx) + * @param datum The payment datum matching the locked UTxO + * @returns CollectResult with tx hash and settlement details + */ + async buildCollectTx( + lockedUtxo: UTxO, + datum: PaymentDatum, + ): Promise { + // 1. Fetch the live Pyth price proof + const feedName = this.feedIdToName(datum.acceptedFeedId); + const resolved = await this.pythClient.getLatestPrice(feedName as never); + + // 2. Compute the required lovelace amount + const required = this.pythClient.computeRequiredLovelace( + resolved, + datum.invoiceUsdCents, + ); + + console.log( + `[CardanoTx] Invoice ${datum.paymentId}: ` + + `$${(datum.invoiceUsdCents / 100).toFixed(2)} USD β†’ ` + + `${required.toLocaleString()} lovelace ` + + `@ ${resolved.priceFloat.toFixed(6)} ${feedName}`, + ); + + // 3. Build the redeemer + const nowSeconds = Math.floor(Date.now() / 1000); + const redeemer: PaymentRedeemer = { + proof: resolved.proof, + nowSeconds, + }; + const encodedRedeemer = encodeRedeemer(redeemer); + + // 4. Build the transaction + // + // Key requirements enforced here: + // - .collectFrom([lockedUtxo], redeemer) β€” spends the validator UTxO + // - .attach.SpendingValidator(validator) β€” attaches compiled script + // - .pay.ToAddress(merchant, lovelace) β€” pays the merchant + // - .addSigner(customerAddress) β€” adds customer as extra signatory + // - .validFrom(now - 30s) β€” tight validity interval + // - .validTo(now + 60s) β€” must confirm within 60 s + // + // The validity interval is critical: the on-chain `now_seconds` + // redeemer field must fall within the tx's validity range so that + // the block-producing node's time check passes deterministically. + + const slotConfig = await this.lucid.currentSlot(); + const validFromSlot = slotConfig - 30; // 30-second back window + const validToSlot = slotConfig + 60; // 60-second forward window + + const tx = await this.lucid + .newTx() + .collectFrom([lockedUtxo], encodedRedeemer) + .attach.SpendingValidator(this.validator) + .pay.ToAddress(datum.merchantAddress, { lovelace: required }) + .addSigner(await this.getCustomerAddress()) + .validFrom(validFromSlot) + .validTo(validToSlot) + .complete(); + + const signed = await tx.sign.withWallet().complete(); + const txHash = await signed.submit(); + + console.log(`[CardanoTx] Collect tx submitted: ${txHash}`); + + return { + txHash, + paidLovelace: required, + priceUsed: resolved, + }; + } + + // ------------------------------------------------------------------------- + // UTxO helpers + // ------------------------------------------------------------------------- + + /** + * Fetch all UTxOs currently locked at the validator address. + * Used by the gateway service to scan for pending payment requests. + */ + async getValidatorUtxos(): Promise { + return this.lucid.utxosAt(this.validatorAddress); + } + + /** + * Find the locked UTxO for a specific payment id. + * + * @param paymentId The hex payment id from the datum + * @returns The matching UTxO, or undefined if not found + */ + async findPaymentUtxo(paymentId: string): Promise { + const utxos = await this.getValidatorUtxos(); + return utxos.find((utxo) => { + if (!utxo.datum) return false; + try { + // Attempt to decode and match the payment id field + const decoded = Data.from(utxo.datum); + if (!(decoded instanceof Constr)) return false; + const fields = decoded.fields as unknown[]; + // Field 0 is payment_id (ByteArray = hex string in Lucid) + return fields[0] === paymentId; + } catch { + return false; + } + }); + } + + // ------------------------------------------------------------------------- + // Private helpers + // ------------------------------------------------------------------------- + + private feedIdToName(feedId: string): string { + const names: Record = { + "2a01deaec9e51a579277b34b122399984d0bbf57e2458a7e42fecd2829867a0d": "ADA/USD", + "e62df6c8b4a85fe1a67db44dc12de5db330f7ac66b72dc658afedf0f4a415b43": "BTC/USD", + "ff61491a931112ddf1bd8147cd1b641375f79f5825126d665480874634fd0ace": "ETH/USD", + "eaa020c61cc479712813461ce153894a96a6c00b21ed0cfc2798d1f9a9e9c94a": "USDC/USD", + }; + const name = names[feedId]; + if (!name) throw new Error(`Unknown feed id: ${feedId}`); + return name; + } + + private async getCustomerAddress(): Promise { + const addr = await this.lucid.wallet().address(); + return addr; + } +} diff --git a/lazer/cardano/integral-payments/src/config.ts b/lazer/cardano/integral-payments/src/config.ts new file mode 100755 index 00000000..dc159b5f --- /dev/null +++ b/lazer/cardano/integral-payments/src/config.ts @@ -0,0 +1,122 @@ +/** + * src/config.ts + * + * Loads and validates gateway configuration from environment variables. + * Call `loadConfig()` once at startup; the resulting object is passed + * into service constructors and is never mutated at runtime. + */ + +import type { CardanoNetwork, GatewayConfig } from "./types.js"; + +// --------------------------------------------------------------------------- +// Pyth feed identifiers (mainnet β€” same values as in contracts/lib/pyth/types.ak) +// --------------------------------------------------------------------------- + +export const FEED_IDS = { + "ADA/USD": + "2a01deaec9e51a579277b34b122399984d0bbf57e2458a7e42fecd2829867a0d", + "BTC/USD": + "e62df6c8b4a85fe1a67db44dc12de5db330f7ac66b72dc658afedf0f4a415b43", + "ETH/USD": + "ff61491a931112ddf1bd8147cd1b641375f79f5825126d665480874634fd0ace", + "USDC/USD": + "eaa020c61cc479712813461ce153894a96a6c00b21ed0cfc2798d1f9a9e9c94a", +} as const; + +/** Reverse map from feed id hex to human-readable name */ +export const FEED_NAMES: Record = Object.fromEntries( + Object.entries(FEED_IDS).map(([name, id]) => [id, name]), +); + +// --------------------------------------------------------------------------- +// Blockfrost endpoint templates +// --------------------------------------------------------------------------- + +const BLOCKFROST_URLS: Record = { + Mainnet: "https://cardano-mainnet.blockfrost.io/api/v0", + Preprod: "https://cardano-preprod.blockfrost.io/api/v0", + Preview: "https://cardano-preview.blockfrost.io/api/v0", +}; + +// --------------------------------------------------------------------------- +// Environment variable loader +// --------------------------------------------------------------------------- + +function required(key: string): string { + const val = process.env[key]; + if (!val) throw new Error(`Missing required environment variable: ${key}`); + return val; +} + +function optional(key: string, fallback: string): string { + return process.env[key] ?? fallback; +} + +function optionalInt(key: string, fallback: number): number { + const raw = process.env[key]; + if (!raw) return fallback; + const n = parseInt(raw, 10); + if (isNaN(n)) throw new Error(`${key} must be an integer, got: ${raw}`); + return n; +} + +function optionalBigInt(key: string, fallback: bigint): bigint { + const raw = process.env[key]; + if (!raw) return fallback; + try { + return BigInt(raw); + } catch { + throw new Error(`${key} must be a bigint-compatible integer, got: ${raw}`); + } +} + +/** + * Load and validate the full gateway configuration from `process.env`. + * Throws a descriptive error for every missing or malformed variable so + * the operator knows exactly what to fix before the service starts. + */ +export function loadConfig(): GatewayConfig { + const network = optional("NETWORK", "Preprod") as CardanoNetwork; + if (!["Mainnet", "Preprod", "Preview"].includes(network)) { + throw new Error( + `NETWORK must be one of Mainnet | Preprod | Preview, got: ${network}`, + ); + } + + const blockfrostApiKey = required("BLOCKFROST_API_KEY"); + const blockfrostUrl = optional( + "BLOCKFROST_URL", + BLOCKFROST_URLS[network], + ); + const hermesUrl = optional( + "PYTH_HERMES_URL", + "https://hermes.pyth.network", + ); + const validatorCbor = required("VALIDATOR_CBOR"); + const trustedSignerKey = required("TRUSTED_SIGNER_KEY"); + + const toleranceBps = optionalInt("TOLERANCE_BPS", 50); + if (toleranceBps < 0 || toleranceBps > 500) { + throw new Error( + `TOLERANCE_BPS must be between 0 and 500 bp, got: ${toleranceBps}`, + ); + } + + const maxPriceAgeSeconds = optionalInt("MAX_PRICE_AGE_SECONDS", 60); + const minDepositLovelace = optionalBigInt( + "MIN_DEPOSIT_LOVELACE", + 2_000_000n, + ); + + return { + network, + blockfrostApiKey, + blockfrostUrl, + hermesUrl, + validatorCbor, + trustedSignerKey, + toleranceBps, + maxPriceAgeSeconds, + minDepositLovelace, + }; +} diff --git a/lazer/cardano/integral-payments/src/gateway/paymentService.ts b/lazer/cardano/integral-payments/src/gateway/paymentService.ts new file mode 100755 index 00000000..d8fd6e67 --- /dev/null +++ b/lazer/cardano/integral-payments/src/gateway/paymentService.ts @@ -0,0 +1,423 @@ +/** + * src/gateway/paymentService.ts + * + * IntegralPayments β€” core payment orchestration service. + * + * This is the main facade consumed by the ERP/CRM plugin modules + * (Dolibarr, Odoo, Tryton). It coordinates the Pyth oracle client + * and the Cardano transaction builder into a single, clean API: + * + * service.createPaymentRequest(...) β†’ builds + submits the lock tx + * service.settlePayment(...) β†’ fetches price + builds + submits collect tx + * service.getPaymentStatus(...) β†’ queries the chain for UTxO state + * service.estimateLovelace(...) β†’ quotes the current price without transacting + * service.startPriceStream(...) β†’ opens an SSE price feed for the UI + * + * The service owns the Lucid instance and is responsible for wallet + * management. ERP modules interact exclusively through this interface + * and never touch Lucid or the Pyth client directly. + * + * Dependencies: + * @lucid-evolution/lucid v0.4.x + * @pythnetwork/hermes-client latest + */ + +import { + Blockfrost, + Lucid, + type UTxO, +} from "@lucid-evolution/lucid"; +import { PythClient, PythClientError } from "../oracle/pythClient.js"; +import { CardanoTransactionBuilder } from "../cardano/transaction.js"; +import type { + CollectResult, + GatewayConfig, + LockResult, + PaymentDatum, + PaymentRequest, + PaymentStatus, + ResolvedPrice, + SupportedFeed, +} from "../types.js"; +import { FEED_IDS } from "../config.js"; + +// --------------------------------------------------------------------------- +// PaymentService +// --------------------------------------------------------------------------- + +export class PaymentService { + private readonly config: GatewayConfig; + private readonly pythClient: PythClient; + private lucid!: Lucid; + private txBuilder!: CardanoTransactionBuilder; + + /** In-memory payment request store. Replace with a database in production. */ + private readonly requests = new Map(); + + private constructor(config: GatewayConfig, pythClient: PythClient) { + this.config = config; + this.pythClient = pythClient; + } + + // ------------------------------------------------------------------------- + // Factory (async construction) + // ------------------------------------------------------------------------- + + /** + * Create and fully initialise a `PaymentService`. + * + * Must be called once before any other method. Establishes the Lucid + * instance connected to Blockfrost and validates the configuration. + * + * @param config Loaded gateway configuration + * @param seed BIP-39 mnemonic for the service wallet + * (generates the address that pays transaction fees and + * creates the lock UTxOs on behalf of the ERP) + */ + static async create( + config: GatewayConfig, + seed: string, + ): Promise { + const pythClient = new PythClient(config); + const service = new PaymentService(config, pythClient); + await service.init(seed); + return service; + } + + private async init(seed: string): Promise { + this.lucid = await Lucid( + new Blockfrost(this.config.blockfrostUrl, this.config.blockfrostApiKey), + this.config.network, + ); + this.lucid.selectWallet.fromSeed(seed); + this.txBuilder = new CardanoTransactionBuilder( + this.lucid, + this.config, + this.pythClient, + ); + + console.log( + `[PaymentService] Initialised on ${this.config.network}. ` + + `Validator address: ${this.txBuilder.getValidatorAddress()}`, + ); + } + + // ------------------------------------------------------------------------- + // Core payment operations + // ------------------------------------------------------------------------- + + /** + * Create a new payment request for an ERP invoice. + * + * 1. Generates a unique `paymentId` from the ERP invoice reference. + * 2. Locks a UTxO at the validator address with the invoice datum. + * 3. Stores the request in the in-memory registry. + * 4. Returns the lock transaction hash and UTxO reference. + * + * The ERP module should persist the returned `utxoRef` alongside the + * invoice record so the payment can be settled or queried later. + * + * @param invoiceRef ERP invoice reference (used to generate paymentId) + * @param merchantAddress Bech32 merchant wallet address + * @param invoiceUsdCents Invoice total in US-cent integer units + * @param acceptedFeed Pyth feed name for the accepted cryptocurrency + * @param customerAddress Bech32 customer wallet address + */ + async createPaymentRequest( + invoiceRef: string, + merchantAddress: string, + invoiceUsdCents: number, + acceptedFeed: SupportedFeed, + customerAddress: string, + ): Promise<{ request: PaymentRequest; lockResult: LockResult }> { + // Derive paymentId from ERP invoice ref + timestamp for uniqueness + const paymentId = this.derivePaymentId(invoiceRef); + + // Resolve the customer's verification key hash from their address + const customerPkh = this.lucid.utils + .getAddressDetails(customerAddress) + .paymentCredential?.hash ?? ""; + if (!customerPkh) { + throw new PaymentServiceError( + `Could not extract PKH from customer address: ${customerAddress}`, + "INVALID_ADDRESS", + ); + } + + const datum: PaymentDatum = { + paymentId, + merchantAddress, + invoiceUsdCents, + acceptedFeedId: FEED_IDS[acceptedFeed], + customerPkh, + createdAt: Math.floor(Date.now() / 1000), + }; + + let lockResult: LockResult; + try { + lockResult = await this.txBuilder.buildLockTx(datum); + } catch (err) { + throw new PaymentServiceError( + `Failed to submit lock transaction: ${String(err)}`, + "LOCK_TX_FAILED", + ); + } + + const request: PaymentRequest = { + datum, + utxoRef: lockResult.utxoRef, + status: "locked", + updatedAt: new Date().toISOString(), + }; + this.requests.set(paymentId, request); + + return { request, lockResult }; + } + + /** + * Settle a payment request by spending the locked UTxO. + * + * This is called once the customer has initiated payment via their + * Cardano wallet. The service: + * 1. Looks up the locked UTxO on-chain. + * 2. Fetches a fresh Pyth price proof. + * 3. Builds and submits the collect transaction. + * 4. Updates the request status to "settling". + * + * The ERP module should poll `getPaymentStatus` until the status + * transitions to "settled". + * + * @param paymentId The paymentId from the original request + * @param customerSeed BIP-39 mnemonic of the customer's wallet + * (used to add the required extra signatory) + */ + async settlePayment( + paymentId: string, + customerSeed: string, + ): Promise { + const request = this.requests.get(paymentId); + if (!request) { + throw new PaymentServiceError( + `Payment request not found: ${paymentId}`, + "NOT_FOUND", + ); + } + if (request.status !== "locked") { + throw new PaymentServiceError( + `Cannot settle a payment in status "${request.status}"`, + "INVALID_STATUS", + ); + } + + // Switch to the customer's wallet for signing + this.lucid.selectWallet.fromSeed(customerSeed); + + // Find the UTxO on-chain + const utxo = await this.txBuilder.findPaymentUtxo(paymentId); + if (!utxo) { + this.updateStatus(paymentId, "expired"); + throw new PaymentServiceError( + `UTxO for payment ${paymentId} not found on-chain β€” may have expired`, + "UTXO_NOT_FOUND", + ); + } + + let collectResult: CollectResult; + try { + collectResult = await this.txBuilder.buildCollectTx( + utxo, + request.datum, + ); + } catch (err) { + if (err instanceof PythClientError) { + throw new PaymentServiceError( + `Oracle error during settlement: ${err.message}`, + "ORACLE_ERROR", + ); + } + this.updateStatus(paymentId, "failed"); + throw new PaymentServiceError( + `Collect transaction failed: ${String(err)}`, + "COLLECT_TX_FAILED", + ); + } + + this.updateStatus(paymentId, "settling"); + return collectResult; + } + + /** + * Confirm a settled payment after on-chain confirmation. + * + * Call this once the ERP module has verified the collect transaction + * is included in a block (e.g. via a Blockfrost webhook or polling). + */ + confirmSettlement(paymentId: string): void { + this.updateStatus(paymentId, "settled"); + console.log(`[PaymentService] Payment ${paymentId} confirmed as settled.`); + } + + // ------------------------------------------------------------------------- + // Price estimation + // ------------------------------------------------------------------------- + + /** + * Estimate how many lovelace a given USD invoice would cost right now. + * + * Does NOT submit any transaction. Use this to display the crypto + * equivalent on the ERP invoice before the customer confirms. + * + * @param invoiceUsdCents Invoice total in US-cent integer units + * @param feed Pyth feed for the target cryptocurrency + * @returns Estimated lovelace amount and the price snapshot + */ + async estimateLovelace( + invoiceUsdCents: number, + feed: SupportedFeed, + ): Promise<{ lovelace: bigint; price: ResolvedPrice }> { + const price = await this.pythClient.getLatestPrice(feed); + const lovelace = this.pythClient.computeRequiredLovelace( + price, + invoiceUsdCents, + ); + return { lovelace, price }; + } + + /** + * Return all prices currently held in the oracle cache. + * Useful for displaying a live price ticker in the ERP UI. + */ + async getAllPrices(): Promise> { + return this.pythClient.refreshAllFeeds(); + } + + // ------------------------------------------------------------------------- + // Streaming + // ------------------------------------------------------------------------- + + /** + * Open an SSE connection to Hermes and push live price updates to `onUpdate`. + * Call `stopPriceStream()` when the ERP UI session closes. + */ + async startPriceStream( + feeds: SupportedFeed[], + onUpdate: (price: ResolvedPrice) => void, + ): Promise { + await this.pythClient.startStream(feeds, onUpdate); + } + + stopPriceStream(): void { + this.pythClient.stopStream(); + } + + // ------------------------------------------------------------------------- + // Status query + // ------------------------------------------------------------------------- + + getPaymentRequest(paymentId: string): PaymentRequest | undefined { + return this.requests.get(paymentId); + } + + /** + * Query the on-chain state of a payment request. + * + * - If the UTxO is still present at the validator β†’ "locked" + * - If the UTxO is gone and we have a collect tx hash β†’ "settled" + * - If enough time has passed without settlement β†’ "expired" + */ + async getPaymentStatus(paymentId: string): Promise { + const request = this.requests.get(paymentId); + if (!request) return "failed"; + + // If already in a terminal state, don't re-query + if (["settled", "failed", "expired"].includes(request.status)) { + return request.status; + } + + const utxo = await this.txBuilder.findPaymentUtxo(paymentId); + if (!utxo) { + // UTxO is gone β€” either settled or expired + const ageMinutes = + (Date.now() / 1000 - request.datum.createdAt) / 60; + const newStatus: PaymentStatus = ageMinutes > 30 ? "expired" : "settled"; + this.updateStatus(paymentId, newStatus); + return newStatus; + } + + return "locked"; + } + + /** + * Return all payment requests along with their current status. + * Used by ERP modules to populate the payments dashboard. + */ + getAllRequests(): PaymentRequest[] { + return Array.from(this.requests.values()); + } + + // ------------------------------------------------------------------------- + // Wallet management + // ------------------------------------------------------------------------- + + /** + * Return the service wallet's bech32 address. + * Needed by the ERP module to display a receiving address for the + * lock-transaction ADA deposit. + */ + async getServiceWalletAddress(): Promise { + return this.lucid.wallet().address(); + } + + /** + * Return the validator address where payment UTxOs are locked. + */ + getValidatorAddress(): string { + return this.txBuilder.getValidatorAddress(); + } + + // ------------------------------------------------------------------------- + // Private helpers + // ------------------------------------------------------------------------- + + /** + * Derive a deterministic 32-byte payment id from an ERP invoice reference. + * Uses a simple hex encoding; replace with a proper hash (e.g. Blake2b-256) + * in production so that the payment id is a fixed 32 bytes regardless of + * invoice ref length. + */ + private derivePaymentId(invoiceRef: string): string { + return Buffer.from(invoiceRef, "utf8").toString("hex").padEnd(64, "0").slice(0, 64); + } + + private updateStatus(paymentId: string, status: PaymentStatus): void { + const req = this.requests.get(paymentId); + if (req) { + req.status = status; + req.updatedAt = new Date().toISOString(); + } + } +} + +// --------------------------------------------------------------------------- +// Error class +// --------------------------------------------------------------------------- + +export type PaymentServiceErrorCode = + | "INVALID_ADDRESS" + | "LOCK_TX_FAILED" + | "COLLECT_TX_FAILED" + | "NOT_FOUND" + | "INVALID_STATUS" + | "UTXO_NOT_FOUND" + | "ORACLE_ERROR" + | "INIT_FAILED"; + +export class PaymentServiceError extends Error { + constructor( + message: string, + public readonly code: PaymentServiceErrorCode, + ) { + super(message); + this.name = "PaymentServiceError"; + } +} diff --git a/lazer/cardano/integral-payments/src/index.ts b/lazer/cardano/integral-payments/src/index.ts new file mode 100755 index 00000000..4d1542fe --- /dev/null +++ b/lazer/cardano/integral-payments/src/index.ts @@ -0,0 +1,44 @@ +/** + * src/index.ts + * + * Public barrel export for the IntegralPayments off-chain backend. + * + * ERP/CRM modules should import only from this file to avoid coupling + * to internal module paths that may change between versions. + * + * Usage: + * import { PaymentService, loadConfig, FEED_IDS } from '@venehsoftw/integral-payments'; + */ + +// Configuration +export { loadConfig, FEED_IDS, FEED_NAMES } from "./config.js"; + +// Core service (main entry point for ERP modules) +export { + PaymentService, + PaymentServiceError, + type PaymentServiceErrorCode, +} from "./gateway/paymentService.js"; + +// Oracle client (exposed for advanced use cases, e.g. price display widgets) +export { + PythClient, + PythClientError, + type PythClientErrorCode, +} from "./oracle/pythClient.js"; + +// Types (re-exported so consumers don't need to import from sub-paths) +export type { + CardanoNetwork, + CollectResult, + GatewayConfig, + LockResult, + PaymentDatum, + PaymentRedeemer, + PaymentRequest, + PaymentStatus, + PriceProof, + ResolvedPrice, + SupportedFeed, + TxResult, +} from "./types.js"; diff --git a/lazer/cardano/integral-payments/src/oracle/pythClient.ts b/lazer/cardano/integral-payments/src/oracle/pythClient.ts new file mode 100755 index 00000000..7afa74fd --- /dev/null +++ b/lazer/cardano/integral-payments/src/oracle/pythClient.ts @@ -0,0 +1,373 @@ +/** + * src/oracle/pythClient.ts + * + * Pyth Hermes API client for IntegralPayments. + * + * Responsibilities: + * - Fetch the latest signed price update for one or more feeds via HTTP. + * - Validate freshness, confidence, and signer key on the received proof. + * - Stream real-time price updates over SSE for the gateway's price cache. + * - Compute fiat β†’ lovelace conversions using the live oracle price. + * + * This module is the single entry-point for all oracle interactions. + * The transaction builder (cardano/transaction.ts) calls this module to + * obtain a proof and the computed lovelace amount before building the + * collect transaction. + * + * Dependencies: + * @pythnetwork/hermes-client (latest) + */ + +import { HermesClient, type PriceUpdate } from "@pythnetwork/hermes-client"; +import { FEED_IDS, FEED_NAMES } from "../config.js"; +import type { + GatewayConfig, + PriceProof, + ResolvedPrice, + SupportedFeed, +} from "../types.js"; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/** Lovelace per ADA */ +const LOVELACE_PER_ADA = 1_000_000n; + +/** Basis-points denominator */ +const BPS_DENOM = 10_000n; + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +/** + * Convert a Hermes `ParsedPriceFeed` entry into our on-chain `PriceProof`. + * + * The Hermes v2 response structure: + * { + * binary: { data: string[] }, // base64-encoded signed VAA(s) + * parsed: [{ + * id: string, + * price: { price: string, conf: string, expo: number, publish_time: number }, + * ema_price: { ... }, + * metadata: { proof_available_time: number, prev_publish_time: number } + * }] + * } + * + * NOTE: The Pyth Hermes API returns the full binary VAA in `binary.data[0]` + * (base64-encoded). For the Cardano on-chain verifier we need the raw + * Ed25519 signature and signer key extracted from the VAA. In a production + * integration, the VAA should be decoded using the Wormhole SDK to extract + * the guardian signature. For the POC we store the entire binary blob as + * the `signature` field and handle decoding in the Aiken contract via the + * Pyth Cardano library once it is available. + * + * The `signerKey` is the Pyth oracle's published Ed25519 public key, + * configured in `trustedSignerKey` from the gateway config and passed + * through here for on-chain verification. + */ +function parsedFeedToProof( + parsed: PriceUpdate["parsed"][number], + binaryVaa: string, + trustedSignerKey: string, +): PriceProof { + return { + feedId: parsed.id.replace(/^0x/, ""), + price: BigInt(parsed.price.price), + conf: BigInt(parsed.price.conf), + exponent: parsed.price.expo, + publishTime: parsed.price.publish_time, + // Store the full base64 VAA; on-chain the Aiken validator extracts the + // Ed25519 signature from it using builtin.verify_ed25519_signature. + signature: Buffer.from(binaryVaa, "base64").toString("hex"), + signerKey: trustedSignerKey, + }; +} + +/** + * Compute the real-value price from raw mantissa + exponent. + * Used only for display and logging β€” all on-chain math stays in integers. + */ +function toFloat(mantissa: bigint, exponent: number): number { + return Number(mantissa) * Math.pow(10, exponent); +} + +// --------------------------------------------------------------------------- +// PythClient +// --------------------------------------------------------------------------- + +export class PythClient { + private readonly hermes: HermesClient; + private readonly config: GatewayConfig; + + /** In-memory price cache: feedId β†’ latest ResolvedPrice */ + private readonly cache = new Map(); + + /** Active SSE event source (if streaming is enabled) */ + private streamSource: EventSource | null = null; + + constructor(config: GatewayConfig) { + this.config = config; + this.hermes = new HermesClient(config.hermesUrl, { + // Retry up to 3 times with exponential back-off on transient errors + timeout: 10_000, + }); + } + + // ------------------------------------------------------------------------- + // Public API + // ------------------------------------------------------------------------- + + /** + * Fetch the latest signed price proof for a single feed. + * + * Validates: + * 1. The observation is not older than `config.maxPriceAgeSeconds`. + * 2. The confidence interval does not exceed 10 % of the price. + * + * Throws `PythClientError` if either check fails so the caller can + * decide whether to abort the payment or widen the tolerance. + * + * @param feed Human-readable feed name, e.g. "ADA/USD" + * @returns A validated `ResolvedPrice` ready for transaction building + */ + async getLatestPrice(feed: SupportedFeed): Promise { + const feedId = FEED_IDS[feed]; + const update = await this.fetchUpdate([feedId]); + const resolved = this.resolveUpdate(update, feedId); + this.validatePrice(resolved); + return resolved; + } + + /** + * Fetch the latest price for every supported feed in a single HTTP round-trip. + * Results are stored in the internal cache. + */ + async refreshAllFeeds(): Promise> { + const allIds = Object.values(FEED_IDS); + const update = await this.fetchUpdate(allIds); + + for (const parsed of update.parsed ?? []) { + const feedId = parsed.id.replace(/^0x/, ""); + const binaryVaa = update.binary?.data?.[0] ?? ""; + const proof = parsedFeedToProof(parsed, binaryVaa, this.config.trustedSignerKey); + const now = Math.floor(Date.now() / 1000); + const resolved: ResolvedPrice = { + proof, + priceFloat: toFloat(proof.price, proof.exponent), + confFloat: toFloat(proof.conf, proof.exponent), + ageSeconds: now - proof.publishTime, + }; + this.cache.set(feedId, resolved); + } + + return this.cache; + } + + /** + * Return the most recently cached price without hitting the network. + * Useful for high-frequency display; always call `getLatestPrice` before + * building a transaction. + */ + getCached(feed: SupportedFeed): ResolvedPrice | undefined { + return this.cache.get(FEED_IDS[feed]); + } + + /** + * Subscribe to real-time price updates via Hermes SSE. + * The callback is invoked on every incoming update for any subscribed feed. + * Call `stopStream()` to close the connection. + * + * @param feeds Feeds to subscribe to + * @param onUpdate Callback invoked with each new price + */ + async startStream( + feeds: SupportedFeed[], + onUpdate: (price: ResolvedPrice) => void, + ): Promise { + if (this.streamSource) this.stopStream(); + + const ids = feeds.map((f) => FEED_IDS[f]); + this.streamSource = await this.hermes.getPriceUpdatesStream(ids); + + this.streamSource.onmessage = (event: MessageEvent) => { + try { + const update: PriceUpdate = JSON.parse(event.data); + for (const parsed of update.parsed ?? []) { + const feedId = parsed.id.replace(/^0x/, ""); + const binaryVaa = update.binary?.data?.[0] ?? ""; + const proof = parsedFeedToProof( + parsed, + binaryVaa, + this.config.trustedSignerKey, + ); + const now = Math.floor(Date.now() / 1000); + const resolved: ResolvedPrice = { + proof, + priceFloat: toFloat(proof.price, proof.exponent), + confFloat: toFloat(proof.conf, proof.exponent), + ageSeconds: now - proof.publishTime, + }; + this.cache.set(feedId, resolved); + onUpdate(resolved); + } + } catch (err) { + console.error("[PythClient] Failed to parse SSE message:", err); + } + }; + + this.streamSource.onerror = (err: Event) => { + console.error("[PythClient] SSE stream error β€” will reconnect:", err); + }; + } + + /** Close the active SSE price stream. */ + stopStream(): void { + this.streamSource?.close(); + this.streamSource = null; + } + + // ------------------------------------------------------------------------- + // Conversion helpers + // ------------------------------------------------------------------------- + + /** + * Compute the required lovelace to settle `invoiceUsdCents` at the price + * in `resolved.proof`. + * + * Uses pure integer arithmetic to match the on-chain Aiken computation + * exactly (see contracts/lib/pyth/verification.ak :: compute_required_lovelace). + * + * Formula: + * required = invoiceUsdCents Γ— 1_000_000 Γ— 10^(-exponent) + * Γ· (price Γ— 100) + */ + computeRequiredLovelace( + resolved: ResolvedPrice, + invoiceUsdCents: number, + ): bigint { + const { price, exponent } = resolved.proof; + // exponent is negative (e.g. -8); negate to get positive power + const scale = 10n ** BigInt(-exponent); + return ( + (BigInt(invoiceUsdCents) * LOVELACE_PER_ADA * scale) / + (price * 100n) + ); + } + + /** + * Apply the slippage tolerance to `requiredLovelace` and return the + * minimum acceptable amount. + * + * lower_bound = required Γ— (10_000 - toleranceBps) / 10_000 + */ + applyTolerance(requiredLovelace: bigint, toleranceBps: number): bigint { + return ( + (requiredLovelace * (BPS_DENOM - BigInt(toleranceBps))) / BPS_DENOM + ); + } + + // ------------------------------------------------------------------------- + // Private helpers + // ------------------------------------------------------------------------- + + private async fetchUpdate(feedIds: string[]): Promise { + // Prefix ids with 0x as required by the Hermes API + const prefixed = feedIds.map((id) => + id.startsWith("0x") ? id : `0x${id}`, + ); + + try { + const update = await this.hermes.getLatestPriceUpdates(prefixed, { + parsed: true, + binary: true, + }); + return update; + } catch (err) { + throw new PythClientError( + `Hermes request failed for feeds ${feedIds.join(", ")}: ${String(err)}`, + "FETCH_FAILED", + ); + } + } + + private resolveUpdate( + update: PriceUpdate, + feedId: string, + ): ResolvedPrice { + const parsed = update.parsed?.find( + (p) => p.id.replace(/^0x/, "") === feedId, + ); + if (!parsed) { + throw new PythClientError( + `Feed ${feedId} (${FEED_NAMES[feedId] ?? "unknown"}) not found in Hermes response`, + "FEED_NOT_FOUND", + ); + } + + const binaryVaa = update.binary?.data?.[0] ?? ""; + const proof = parsedFeedToProof( + parsed, + binaryVaa, + this.config.trustedSignerKey, + ); + const now = Math.floor(Date.now() / 1000); + + return { + proof, + priceFloat: toFloat(proof.price, proof.exponent), + confFloat: toFloat(proof.conf, proof.exponent), + ageSeconds: now - proof.publishTime, + }; + } + + private validatePrice(resolved: ResolvedPrice): void { + const { ageSeconds, proof } = resolved; + + // 1. Freshness check + if (ageSeconds < 0) { + throw new PythClientError( + `Price publish_time is in the future (age ${ageSeconds}s) β€” clock skew?`, + "FUTURE_TIMESTAMP", + ); + } + if (ageSeconds > this.config.maxPriceAgeSeconds) { + throw new PythClientError( + `Price for feed ${proof.feedId} is stale: ${ageSeconds}s old (max ${this.config.maxPriceAgeSeconds}s)`, + "STALE_PRICE", + ); + } + + // 2. Confidence check (conf must not exceed 10 % of price) + if (proof.conf * 10n > proof.price) { + throw new PythClientError( + `Confidence interval too wide for feed ${proof.feedId}: ` + + `conf=${proof.conf}, price=${proof.price} ` + + `(ratio=${Number((proof.conf * 100n) / proof.price)}%)`, + "WIDE_CONFIDENCE", + ); + } + } +} + +// --------------------------------------------------------------------------- +// Error class +// --------------------------------------------------------------------------- + +export type PythClientErrorCode = + | "FETCH_FAILED" + | "FEED_NOT_FOUND" + | "STALE_PRICE" + | "FUTURE_TIMESTAMP" + | "WIDE_CONFIDENCE"; + +export class PythClientError extends Error { + constructor( + message: string, + public readonly code: PythClientErrorCode, + ) { + super(message); + this.name = "PythClientError"; + } +} diff --git a/lazer/cardano/integral-payments/src/tests/paymentService.test.ts b/lazer/cardano/integral-payments/src/tests/paymentService.test.ts new file mode 100755 index 00000000..491cacc1 --- /dev/null +++ b/lazer/cardano/integral-payments/src/tests/paymentService.test.ts @@ -0,0 +1,278 @@ +/** + * src/tests/paymentService.test.ts + * + * Unit tests for src/gateway/paymentService.ts + * + * Uses Vitest with full mocking of Lucid Evolution and PythClient so the + * service logic can be tested without a live Cardano node or oracle. + * + * Run: npm test + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { + PaymentService, + PaymentServiceError, +} from "../gateway/paymentService.js"; +import type { GatewayConfig } from "../types.js"; + +// --------------------------------------------------------------------------- +// Test configuration +// --------------------------------------------------------------------------- + +const TEST_CONFIG: GatewayConfig = { + network: "Preprod", + blockfrostApiKey: "preprod_test", + blockfrostUrl: "https://cardano-preprod.blockfrost.io/api/v0", + hermesUrl: "https://hermes.pyth.network", + validatorCbor: "59010f010000deadbeef", + trustedSignerKey: + "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a", + toleranceBps: 50, + maxPriceAgeSeconds: 60, + minDepositLovelace: 2_000_000n, +}; + +const MERCHANT_ADDRESS = + "addr_test1qz0k5q4vscx9l5mzsn0y99kfqajlm8ljpszf0gn9rg70hfpflxlwfvj9k84y0hmhv2kxq3h9h8jkmx2kjyp4gst6pxqsxdg56n"; +const CUSTOMER_ADDRESS = + "addr_test1qr0vj9vswz3chnkfe8ywl5zq8fxzjh97emajmxcq0gxpnqhvj63jy0nq7j7x5c4ys4x3fwrjlhnkj7spf6xp0c3rjsqdnqmgq"; + +// --------------------------------------------------------------------------- +// Mock dependencies +// --------------------------------------------------------------------------- + +const mockBuildLockTx = vi.fn(); +const mockBuildCollectTx = vi.fn(); +const mockFindPaymentUtxo = vi.fn(); +const mockGetValidatorAddress = vi.fn().mockReturnValue( + "addr_test1w_validator_address", +); + +vi.mock("../cardano/transaction.js", () => ({ + CardanoTransactionBuilder: vi.fn().mockImplementation(() => ({ + buildLockTx: mockBuildLockTx, + buildCollectTx: mockBuildCollectTx, + findPaymentUtxo: mockFindPaymentUtxo, + getValidatorAddress: mockGetValidatorAddress, + getValidatorUtxos: vi.fn().mockResolvedValue([]), + })), +})); + +const mockGetLatestPrice = vi.fn(); +const mockComputeRequiredLovelace = vi.fn().mockReturnValue(28_571_428n); +const mockRefreshAllFeeds = vi.fn().mockResolvedValue(new Map()); + +vi.mock("../oracle/pythClient.js", () => ({ + PythClient: vi.fn().mockImplementation(() => ({ + getLatestPrice: mockGetLatestPrice, + computeRequiredLovelace: mockComputeRequiredLovelace, + refreshAllFeeds: mockRefreshAllFeeds, + startStream: vi.fn(), + stopStream: vi.fn(), + applyTolerance: vi.fn((v: bigint) => v), + })), + PythClientError: class PythClientError extends Error { + constructor(message: string, public code: string) { super(message); } + }, +})); + +vi.mock("@lucid-evolution/lucid", () => ({ + Lucid: vi.fn().mockResolvedValue({ + selectWallet: { + fromSeed: vi.fn(), + }, + wallet: vi.fn().mockReturnValue({ + address: vi.fn().mockResolvedValue(CUSTOMER_ADDRESS), + }), + utils: { + getAddressDetails: vi.fn().mockReturnValue({ + paymentCredential: { + type: "Key", + hash: "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", + }, + }), + validatorToAddress: vi.fn().mockReturnValue("addr_test1w_validator_address"), + }, + }), + Blockfrost: vi.fn().mockImplementation(() => ({})), +})); + +// --------------------------------------------------------------------------- +// Test suite +// --------------------------------------------------------------------------- + +describe("PaymentService.createPaymentRequest", () => { + let service: PaymentService; + + beforeEach(async () => { + vi.clearAllMocks(); + mockBuildLockTx.mockResolvedValue({ + txHash: "abc123", + utxoRef: "abc123#0", + }); + service = await PaymentService.create(TEST_CONFIG, "test seed phrase here"); + }); + + it("creates a payment request and returns a lock result", async () => { + const { request, lockResult } = await service.createPaymentRequest( + "INV-2024-001", + MERCHANT_ADDRESS, + 1000, // $10.00 + "ADA/USD", + CUSTOMER_ADDRESS, + ); + + expect(lockResult.txHash).toBe("abc123"); + expect(lockResult.utxoRef).toBe("abc123#0"); + expect(request.status).toBe("locked"); + expect(request.datum.invoiceUsdCents).toBe(1000); + expect(request.datum.merchantAddress).toBe(MERCHANT_ADDRESS); + }); + + it("stores the request so it can be queried by paymentId", async () => { + const { request } = await service.createPaymentRequest( + "INV-2024-002", + MERCHANT_ADDRESS, + 500, + "ADA/USD", + CUSTOMER_ADDRESS, + ); + + const fetched = service.getPaymentRequest(request.datum.paymentId); + expect(fetched).toBeDefined(); + expect(fetched?.status).toBe("locked"); + }); + + it("throws LOCK_TX_FAILED when the lock transaction is rejected", async () => { + mockBuildLockTx.mockRejectedValueOnce(new Error("Insufficient UTxOs")); + + await expect( + service.createPaymentRequest( + "INV-2024-003", + MERCHANT_ADDRESS, + 100, + "ADA/USD", + CUSTOMER_ADDRESS, + ), + ).rejects.toMatchObject({ code: "LOCK_TX_FAILED" }); + }); +}); + +// --------------------------------------------------------------------------- + +describe("PaymentService.settlePayment", () => { + let service: PaymentService; + let paymentId: string; + + beforeEach(async () => { + vi.clearAllMocks(); + mockBuildLockTx.mockResolvedValue({ txHash: "lock_tx_hash", utxoRef: "lock_tx_hash#0" }); + mockBuildCollectTx.mockResolvedValue({ + txHash: "collect_tx_hash", + paidLovelace: 28_571_428n, + priceUsed: { priceFloat: 0.35, ageSeconds: 5 }, + }); + mockFindPaymentUtxo.mockResolvedValue({ txHash: "lock_tx_hash", outputIndex: 0 }); + + service = await PaymentService.create(TEST_CONFIG, "test seed phrase here"); + + const { request } = await service.createPaymentRequest( + "INV-SETTLE-001", + MERCHANT_ADDRESS, + 1000, + "ADA/USD", + CUSTOMER_ADDRESS, + ); + paymentId = request.datum.paymentId; + }); + + it("settles a locked payment and returns collect result", async () => { + const result = await service.settlePayment(paymentId, "customer seed phrase"); + + expect(result.txHash).toBe("collect_tx_hash"); + expect(result.paidLovelace).toBe(28_571_428n); + }); + + it("updates status to settling after successful collect tx", async () => { + await service.settlePayment(paymentId, "customer seed phrase"); + const req = service.getPaymentRequest(paymentId); + expect(req?.status).toBe("settling"); + }); + + it("throws NOT_FOUND for an unknown paymentId", async () => { + await expect( + service.settlePayment("nonexistent_id", "seed"), + ).rejects.toMatchObject({ code: "NOT_FOUND" }); + }); + + it("throws INVALID_STATUS when already settling", async () => { + await service.settlePayment(paymentId, "customer seed phrase"); + // Second attempt on same request (now in "settling" state) + await expect( + service.settlePayment(paymentId, "customer seed phrase"), + ).rejects.toMatchObject({ code: "INVALID_STATUS" }); + }); + + it("throws UTXO_NOT_FOUND and marks expired when UTxO is gone", async () => { + mockFindPaymentUtxo.mockResolvedValueOnce(undefined); + + await expect( + service.settlePayment(paymentId, "customer seed phrase"), + ).rejects.toMatchObject({ code: "UTXO_NOT_FOUND" }); + + const req = service.getPaymentRequest(paymentId); + expect(req?.status).toBe("expired"); + }); +}); + +// --------------------------------------------------------------------------- + +describe("PaymentService.estimateLovelace", () => { + let service: PaymentService; + + beforeEach(async () => { + vi.clearAllMocks(); + const now = Math.floor(Date.now() / 1000); + mockGetLatestPrice.mockResolvedValue({ + proof: { price: 35_000_000n, exponent: -8, feedId: "ada_usd", conf: 175_000n, publishTime: now - 5, signature: "", signerKey: "" }, + priceFloat: 0.35, + confFloat: 0.00175, + ageSeconds: 5, + }); + mockComputeRequiredLovelace.mockReturnValue(28_571_428n); + service = await PaymentService.create(TEST_CONFIG, "seed phrase"); + }); + + it("returns lovelace estimate and price snapshot", async () => { + const { lovelace, price } = await service.estimateLovelace(1000, "ADA/USD"); + expect(lovelace).toBe(28_571_428n); + expect(price.priceFloat).toBeCloseTo(0.35, 2); + }); +}); + +// --------------------------------------------------------------------------- + +describe("PaymentService.confirmSettlement", () => { + let service: PaymentService; + let paymentId: string; + + beforeEach(async () => { + vi.clearAllMocks(); + mockBuildLockTx.mockResolvedValue({ txHash: "tx1", utxoRef: "tx1#0" }); + mockBuildCollectTx.mockResolvedValue({ txHash: "tx2", paidLovelace: 1_000_000n, priceUsed: {} }); + mockFindPaymentUtxo.mockResolvedValue({ txHash: "tx1", outputIndex: 0 }); + + service = await PaymentService.create(TEST_CONFIG, "seed"); + const { request } = await service.createPaymentRequest( + "INV-CONFIRM-001", MERCHANT_ADDRESS, 100, "ADA/USD", CUSTOMER_ADDRESS, + ); + paymentId = request.datum.paymentId; + await service.settlePayment(paymentId, "customer seed"); + }); + + it("transitions status to settled", () => { + service.confirmSettlement(paymentId); + expect(service.getPaymentRequest(paymentId)?.status).toBe("settled"); + }); +}); diff --git a/lazer/cardano/integral-payments/src/tests/pythClient.test.ts b/lazer/cardano/integral-payments/src/tests/pythClient.test.ts new file mode 100755 index 00000000..55c216e4 --- /dev/null +++ b/lazer/cardano/integral-payments/src/tests/pythClient.test.ts @@ -0,0 +1,236 @@ +/** + * src/tests/pythClient.test.ts + * + * Unit tests for src/oracle/pythClient.ts + * + * These tests mock the Hermes API using Vitest's `vi.mock` so they run + * fully offline without any network access. + * + * Run: npm test + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { PythClient, PythClientError } from "../oracle/pythClient.js"; +import type { GatewayConfig } from "../types.js"; + +// --------------------------------------------------------------------------- +// Test config fixture +// --------------------------------------------------------------------------- + +const TEST_CONFIG: GatewayConfig = { + network: "Preprod", + blockfrostApiKey: "test_key", + blockfrostUrl: "https://cardano-preprod.blockfrost.io/api/v0", + hermesUrl: "https://hermes.pyth.network", + validatorCbor: "deadbeef", + trustedSignerKey: + "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a", + toleranceBps: 50, + maxPriceAgeSeconds: 60, + minDepositLovelace: 2_000_000n, +}; + +// Feed ids +const ADA_USD_FEED = + "2a01deaec9e51a579277b34b122399984d0bbf57e2458a7e42fecd2829867a0d"; + +// --------------------------------------------------------------------------- +// Mock HermesClient +// --------------------------------------------------------------------------- + +const mockGetLatestPriceUpdates = vi.fn(); + +vi.mock("@pythnetwork/hermes-client", () => ({ + HermesClient: vi.fn().mockImplementation(() => ({ + getLatestPriceUpdates: mockGetLatestPriceUpdates, + getPriceUpdatesStream: vi.fn(), + })), +})); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Build a valid fake Hermes response for ADA/USD at $0.35 */ +function makeHermesResponse(overrides?: { + price?: string; + conf?: string; + expo?: number; + publish_time?: number; +}) { + const now = Math.floor(Date.now() / 1000); + return { + parsed: [ + { + id: `0x${ADA_USD_FEED}`, + price: { + price: overrides?.price ?? "35000000", + conf: overrides?.conf ?? "175000", + expo: overrides?.expo ?? -8, + publish_time: overrides?.publish_time ?? now - 5, // 5 s ago β€” fresh + }, + ema_price: { + price: "35100000", + conf: "200000", + expo: -8, + publish_time: now - 5, + }, + metadata: { + proof_available_time: now, + prev_publish_time: now - 400, + }, + }, + ], + binary: { + data: [Buffer.from("fake_vaa_bytes").toString("base64")], + }, + }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("PythClient.getLatestPrice", () => { + let client: PythClient; + + beforeEach(() => { + vi.clearAllMocks(); + client = new PythClient(TEST_CONFIG); + }); + + it("returns a resolved price for a valid fresh response", async () => { + mockGetLatestPriceUpdates.mockResolvedValueOnce(makeHermesResponse()); + + const resolved = await client.getLatestPrice("ADA/USD"); + + expect(resolved.proof.feedId).toBe(ADA_USD_FEED); + expect(resolved.proof.price).toBe(35_000_000n); + expect(resolved.proof.exponent).toBe(-8); + expect(resolved.priceFloat).toBeCloseTo(0.35, 6); + expect(resolved.ageSeconds).toBeGreaterThanOrEqual(0); + expect(resolved.ageSeconds).toBeLessThanOrEqual(10); + }); + + it("throws STALE_PRICE when publish_time is older than maxPriceAgeSeconds", async () => { + const staleTime = Math.floor(Date.now() / 1000) - 90; // 90 s ago + mockGetLatestPriceUpdates.mockResolvedValueOnce( + makeHermesResponse({ publish_time: staleTime }), + ); + + await expect(client.getLatestPrice("ADA/USD")).rejects.toThrow( + PythClientError, + ); + await expect(client.getLatestPrice("ADA/USD")).rejects.toMatchObject({ + code: "STALE_PRICE", + }); + }); + + it("throws FUTURE_TIMESTAMP when publish_time is in the future", async () => { + const futureTime = Math.floor(Date.now() / 1000) + 30; + mockGetLatestPriceUpdates.mockResolvedValueOnce( + makeHermesResponse({ publish_time: futureTime }), + ); + + await expect(client.getLatestPrice("ADA/USD")).rejects.toMatchObject({ + code: "FUTURE_TIMESTAMP", + }); + }); + + it("throws WIDE_CONFIDENCE when conf > 10 % of price", async () => { + // price = 35_000_000, conf = 4_000_000 β†’ 11.4 % β†’ too wide + mockGetLatestPriceUpdates.mockResolvedValueOnce( + makeHermesResponse({ conf: "4000000" }), + ); + + await expect(client.getLatestPrice("ADA/USD")).rejects.toMatchObject({ + code: "WIDE_CONFIDENCE", + }); + }); + + it("throws FETCH_FAILED when HermesClient throws a network error", async () => { + mockGetLatestPriceUpdates.mockRejectedValueOnce( + new Error("Connection refused"), + ); + + await expect(client.getLatestPrice("ADA/USD")).rejects.toMatchObject({ + code: "FETCH_FAILED", + }); + }); +}); + +// --------------------------------------------------------------------------- + +describe("PythClient.computeRequiredLovelace", () => { + let client: PythClient; + + beforeEach(() => { + client = new PythClient(TEST_CONFIG); + }); + + it("computes correct lovelace for $10.00 at ADA = $0.35", async () => { + mockGetLatestPriceUpdates.mockResolvedValueOnce(makeHermesResponse()); + const resolved = await client.getLatestPrice("ADA/USD"); + + const lovelace = client.computeRequiredLovelace(resolved, 1000); // $10.00 + // Expected: 1000 * 1_000_000 * 10^8 / (35_000_000 * 100) = 28_571_428 + expect(lovelace).toBeGreaterThanOrEqual(28_571_427n); + expect(lovelace).toBeLessThanOrEqual(28_571_429n); + }); + + it("computes correct lovelace for $1.00 at ADA = $1.00", async () => { + mockGetLatestPriceUpdates.mockResolvedValueOnce( + makeHermesResponse({ price: "100000000" }), // $1.00 + ); + const resolved = await client.getLatestPrice("ADA/USD"); + + const lovelace = client.computeRequiredLovelace(resolved, 100); // $1.00 + // Expected: 100 * 1_000_000 * 10^8 / (100_000_000 * 100) = 1_000_000 + expect(lovelace).toBe(1_000_000n); + }); + + it("lovelace increases as price decreases (inverse relationship)", async () => { + // At $0.35: lovelace for $10 + mockGetLatestPriceUpdates.mockResolvedValueOnce(makeHermesResponse()); + const lowPrice = await client.getLatestPrice("ADA/USD"); + const lovelaceLow = client.computeRequiredLovelace(lowPrice, 1000); + + // At $0.70: lovelace for $10 (should be half) + mockGetLatestPriceUpdates.mockResolvedValueOnce( + makeHermesResponse({ price: "70000000" }), + ); + const highPrice = await client.getLatestPrice("ADA/USD"); + const lovelaceHigh = client.computeRequiredLovelace(highPrice, 1000); + + expect(lovelaceLow).toBeGreaterThan(lovelaceHigh); + // Should be roughly 2Γ— as many lovelace at half the price + const ratio = Number(lovelaceLow) / Number(lovelaceHigh); + expect(ratio).toBeCloseTo(2, 0); + }); +}); + +// --------------------------------------------------------------------------- + +describe("PythClient.applyTolerance", () => { + let client: PythClient; + + beforeEach(() => { + client = new PythClient(TEST_CONFIG); + }); + + it("returns exact amount for 0 bps tolerance", () => { + const result = client.applyTolerance(10_000_000n, 0); + expect(result).toBe(10_000_000n); + }); + + it("returns 99.5% of amount for 50 bps tolerance", () => { + // 10_000_000 * (10_000 - 50) / 10_000 = 10_000_000 * 9950 / 10_000 = 9_950_000 + const result = client.applyTolerance(10_000_000n, 50); + expect(result).toBe(9_950_000n); + }); + + it("returns 99% of amount for 100 bps tolerance", () => { + const result = client.applyTolerance(10_000_000n, 100); + expect(result).toBe(9_900_000n); + }); +}); diff --git a/lazer/cardano/integral-payments/src/types.ts b/lazer/cardano/integral-payments/src/types.ts new file mode 100755 index 00000000..0ee1a1ec --- /dev/null +++ b/lazer/cardano/integral-payments/src/types.ts @@ -0,0 +1,153 @@ +/** + * src/types.ts + * + * Shared TypeScript types for the IntegralPayments off-chain backend. + * These types mirror the on-chain Aiken types defined in contracts/lib/pyth/types.ak + * and contracts/validators/payment_gateway.ak so that both layers speak the + * same data vocabulary. + */ + +// --------------------------------------------------------------------------- +// Pyth oracle types +// --------------------------------------------------------------------------- + +/** + * A signed Pyth Lazer price observation, as returned by the Hermes API + * and prepared for on-chain submission as a Cardano redeemer. + * + * Field names and semantics match the on-chain `PriceProof` Aiken type. + */ +export interface PriceProof { + /** 32-byte hex feed identifier (without 0x prefix) */ + feedId: string; + /** Raw price mantissa β€” real price = price Γ— 10^exponent */ + price: bigint; + /** Confidence interval mantissa β€” same exponent as price */ + conf: bigint; + /** Negative power-of-ten exponent (e.g. -8 means Γ—10⁻⁸) */ + exponent: number; + /** Unix timestamp (seconds) of the Pythnet observation */ + publishTime: number; + /** 64-byte Ed25519 signature (hex, no 0x prefix) */ + signature: string; + /** 32-byte Ed25519 public key of the Pyth signer (hex, no 0x prefix) */ + signerKey: string; +} + +/** + * The human-readable price computed from a raw PriceProof. + * Used for display and logging; not sent on-chain. + */ +export interface ResolvedPrice { + proof: PriceProof; + /** Price as a floating-point number for display only */ + priceFloat: number; + /** Confidence interval as a floating-point number */ + confFloat: number; + /** Age of the price observation in seconds at the time of resolution */ + ageSeconds: number; +} + +/** Supported Cardano-native price feeds */ +export type SupportedFeed = "ADA/USD" | "BTC/USD" | "ETH/USD" | "USDC/USD"; + +// --------------------------------------------------------------------------- +// Payment gateway types +// --------------------------------------------------------------------------- + +/** Network identifier β€” must match the Lucid provider network */ +export type CardanoNetwork = "Mainnet" | "Preprod" | "Preview"; + +/** + * The datum attached to a payment-request UTxO locked at the validator. + * Mirrors the on-chain `PaymentDatum` Aiken type exactly. + */ +export interface PaymentDatum { + /** Unique identifier derived from the ERP invoice reference (hex bytes) */ + paymentId: string; + /** Bech32 merchant address β€” receives the lovelace at settlement */ + merchantAddress: string; + /** Invoice amount in US-cent integer units (e.g. $10.50 β†’ 1050) */ + invoiceUsdCents: number; + /** 32-byte Pyth feed id for the asset accepted for this invoice (hex) */ + acceptedFeedId: string; + /** Verification key hash of the customer authorised to settle (hex) */ + customerPkh: string; + /** Unix timestamp (seconds) when this request was created */ + createdAt: number; +} + +/** + * The redeemer supplied by the customer to spend the locked UTxO. + * Mirrors the on-chain `PaymentRedeemer` Aiken type exactly. + */ +export interface PaymentRedeemer { + proof: PriceProof; + /** Lower bound of the tx validity interval as Unix seconds */ + nowSeconds: number; +} + +/** + * A payment request in its full lifecycle representation. + * Combines the on-chain datum with off-chain tracking metadata. + */ +export interface PaymentRequest { + datum: PaymentDatum; + /** Cardano UTxO reference (txHash#index) of the locked UTxO */ + utxoRef?: string; + /** Current lifecycle status */ + status: PaymentStatus; + /** ISO-8601 timestamp of the last status change */ + updatedAt: string; +} + +export type PaymentStatus = + | "pending" // Created by the ERP module, not yet locked on-chain + | "locked" // UTxO locked at the validator address + | "settling" // Collect transaction submitted, awaiting confirmation + | "settled" // On-chain confirmed β€” invoice is paid + | "expired" // Payment window elapsed without settlement + | "failed"; // On-chain transaction rejected + +// --------------------------------------------------------------------------- +// Transaction result types +// --------------------------------------------------------------------------- + +export interface TxResult { + txHash: string; + /** Estimated slot at which the transaction will be confirmed */ + estimatedConfirmationSlot?: number; +} + +export interface LockResult extends TxResult { + /** UTxO reference of the newly locked payment request UTxO */ + utxoRef: string; +} + +export interface CollectResult extends TxResult { + /** Lovelace amount actually paid to the merchant */ + paidLovelace: bigint; + /** Pyth price used for the conversion */ + priceUsed: ResolvedPrice; +} + +// --------------------------------------------------------------------------- +// Configuration types +// --------------------------------------------------------------------------- + +export interface GatewayConfig { + network: CardanoNetwork; + blockfrostApiKey: string; + blockfrostUrl: string; + hermesUrl: string; + /** Hex-encoded compiled validator CBOR (from plutus.json after `aiken build`) */ + validatorCbor: string; + /** 32-byte Ed25519 trusted signer key embedded in the validator (hex) */ + trustedSignerKey: string; + /** Slippage tolerance in basis points (e.g. 50 = 0.50 %) */ + toleranceBps: number; + /** Maximum price age in seconds before the gateway refuses to settle */ + maxPriceAgeSeconds: number; + /** Minimum ADA deposit locked with each payment request UTxO (lovelace) */ + minDepositLovelace: bigint; +} diff --git a/lazer/cardano/integral-payments/tsconfig.json b/lazer/cardano/integral-payments/tsconfig.json new file mode 100755 index 00000000..7b110dc0 --- /dev/null +++ b/lazer/cardano/integral-payments/tsconfig.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + /* Language & Target */ + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + + /* Emit */ + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + + /* Strict */ + "strict": true, + "exactOptionalPropertyTypes": true, + "noUncheckedIndexedAccess": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + + /* Interop */ + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "resolveJsonModule": true, + + /* Misc */ + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"] +}