diff --git a/lazer/cardano/pythacoin/.gitignore b/lazer/cardano/pythacoin/.gitignore new file mode 100644 index 00000000..2994e07c --- /dev/null +++ b/lazer/cardano/pythacoin/.gitignore @@ -0,0 +1,26 @@ +target +.history +.env +/.bsp +.bloop/ +/.idea +/.idea_modules +/.classpath +/.project +/.settings +.scala-build +*.plutus +scala-cli.json +.DS_Store +.metals +.vscode +.direnv +metals.sbt +/plutus-conformance +mnemonic.txt +cardano-cli +cardano-address +blockfrost_api_key.txt +/frontend/node_modules/ +/frontend/dist/ +/frontend/tsconfig.tsbuildinfo diff --git a/lazer/cardano/pythacoin/.scalafmt.conf b/lazer/cardano/pythacoin/.scalafmt.conf new file mode 100644 index 00000000..fa6b4592 --- /dev/null +++ b/lazer/cardano/pythacoin/.scalafmt.conf @@ -0,0 +1,6 @@ +version = "3.7.14" +runner.dialect = scala3 +indent.main = 4 +maxColumn = 100 +rewrite.imports.sort = scalastyle +rewrite.imports.expand = false \ No newline at end of file diff --git a/lazer/cardano/pythacoin/LICENSE b/lazer/cardano/pythacoin/LICENSE new file mode 100644 index 00000000..d6456956 --- /dev/null +++ b/lazer/cardano/pythacoin/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/lazer/cardano/pythacoin/README.md b/lazer/cardano/pythacoin/README.md new file mode 100644 index 00000000..e06fc1d3 --- /dev/null +++ b/lazer/cardano/pythacoin/README.md @@ -0,0 +1,74 @@ +# Team LANTR – Pythathon Submission + +## Details + +- **Team Name:** LANTR +- **Submission Name:** Pythacoin – CDP-based stablecoin on Cardano using Pyth Lazer +- **Team Members:** Captain Alex Nemish (@nau) +- **Contact:** alex@lantr.io + +## Demo + +https://youtu.be/RK7WsZQiG54 + +## Project Description + +Pythacoin is a fully functional CDP (Collateralized Debt Position) stablecoin protocol on Cardano +that uses Pyth Lazer price feeds for real-time ADA/USD pricing. + +Users lock ADA as collateral to mint PUSD, a synthetic USD-pegged stablecoin. The protocol enforces +a 95% max LTV for minting and a 90% liquidation threshold — when a CDP becomes undercollateralized, +anyone can liquidate it by repaying the debt and claiming the collateral. + +### How it uses Pyth + +- **On-chain:** The Plutus V3 validator reads ADA/USD prices directly from Pyth Lazer withdrawal + redeemers. It locates the Pyth State UTxO in reference inputs, extracts the withdraw script hash, + then parses the binary price payload (feed ID 16, ADA/USD) from the corresponding withdrawal + redeemer. This is a full on-chain Pyth Lazer integration in Scalus (Scala-to-Plutus compiler) — no + Aiken dependency. +- **Off-chain:** The backend fetches signed price updates via the Pyth Lazer REST API (`solana` + format) and includes them as withdrawal redeemers in every price-dependent transaction (open, + borrow, liquidate). + +### Features + +- **Open CDP** — lock ADA, optionally borrow PUSD +- **Borrow** — mint additional PUSD against existing collateral +- **Repay** — burn PUSD to reduce debt +- **Close** — repay all debt, reclaim collateral +- **Liquidate** — anyone can liquidate CDPs above 90% LTV +- **Live price feed** — real-time ADA/USD from Pyth Lazer (feed ID 16) +- **Web frontend** — React app with CIP-30 wallet integration (Nami, Eternl, Lace) + +### Tech Stack + +- **Smart contract:** Scala 3 + [Scalus](https://scalus.org) (Plutus V3, combined mint+spend + validator) +- **Backend:** Scala 3, Tapir REST API, Scalus transaction builder +- **Frontend:** React 19, TypeScript, Vite, Tailwind CSS +- **Oracle:** Pyth Lazer (ADA/USD feed ID 16, preprod policy + `d799d287105dea9377cdf9ea8502a83d2b9eb2d2050a8aea800a21e6`) +- **Network:** Cardano PreProd + +### Source Code + +- `src/main/scala/pythacoin/onchain/CdpValidator.scala` — on-chain validator with Pyth integration +- `src/main/scala/pythacoin/Server.scala` — REST API and transaction building +- `src/test/scala/pythacoin/CdpValidatorTest.scala` — unit tests (11 passing) +- `frontend/` — React frontend + +### Running + +```bash +# If you have Nix, just run `nix develop` to get all dependencies + +# Backend (requires BLOCKFROST_API_KEY and PYTH_KEY in .env) +sbt "runMain pythacoin.main start" + +# Tests +sbtn test + +# Frontend +cd frontend && npm install && npm run dev +``` diff --git a/lazer/cardano/pythacoin/Specification.md b/lazer/cardano/pythacoin/Specification.md new file mode 100644 index 00000000..5201fcd3 --- /dev/null +++ b/lazer/cardano/pythacoin/Specification.md @@ -0,0 +1,469 @@ +# Pythacoin – synthetic CDP-based stablecoin using Pyth ADA/USD price oracle + +## Project Overview + +Pythacoin – synthetic CDP-based stablecoin using Pyth oracles network for ADA/USD price oracle. +It's a project for Pythaton – Pyth hackathon. + +## Context + +- https://pyth.network +- https://docs.pyth.network/price-feeds/pro/integrate-as-consumer/cardano +- https://github.com/pyth-network/pyth-examples + +We'll use Cardano Preprod for running the demo. +Read `.env` file for `BLOCKFROST_API_KEY` and `PYTH_KEY` (Pyth Lazer access token). +Use Scalus for DApp development. +Use Emulator for testing. +Package name: `pythacoin`. + +## Architecture + +### Combined Validator (mint + spend) + +A single Plutus V3 combined validator handles both spending (CDP logic) and minting (PUSD + CDP NFT). +The same script hash serves as both the CDP script address and the PUSD/NFT policy ID. + +**Token names under the combined policy ID:** +- `"PUSD"` – the stablecoin token +- Unique per-CDP token name (e.g., derived from UTxO being consumed) – the CDP NFT + +### CDP Datum + +``` +CdpDatum: + owner: PubKeyHash -- who owns this CDP + debt: Integer -- PUSD amount minted against this CDP +``` + +Collateral is the ADA value in the UTxO (not stored in datum). + +### LTV Calculation + +``` +LTV = debt / (collateral_ada * ada_usd_price) +``` + +- **Max minting LTV**: 95% – Alice can mint/borrow PUSD up to 95% LTV +- **Liquidation threshold**: 90% – when LTV > 90%, anyone can liquidate + +This is intentional for the demo: Alice opens a CDP, increases LTV above 90%, and Bob liquidates her. + +### CDP NFT + +Each CDP UTxO contains a unique NFT (minted under the combined policy ID) to: +- Uniquely identify the CDP +- Prevent double-satisfaction attacks + +No receipt NFT for the user. Ownership is verified by checking the transaction is signed +by the `owner` PubKeyHash from the datum. + +## Smart Contract – Spend (CDP Actions) + +### Open CDP +- Consumes a UTxO to derive unique CDP NFT token name +- Mints 1 CDP NFT (unique token name) under the combined policy +- Optionally mints PUSD (if borrowing at open time) +- Creates CDP UTxO at script address with: + - Value: locked ADA + CDP NFT + - Datum: `CdpDatum(owner, debt)` +- Validates: LTV <= 95% (if minting PUSD), tx signed by owner + +### Borrow (mint more PUSD) +- Spends existing CDP UTxO +- Mints additional PUSD +- Creates new CDP UTxO with updated debt +- Validates: tx signed by owner, new LTV <= 95% + +### Repay (burn PUSD) +- Spends existing CDP UTxO +- Burns PUSD +- Creates new CDP UTxO with reduced debt +- Optionally withdraws excess ADA collateral +- Validates: tx signed by owner, new LTV <= 95% (if withdrawing collateral) + +### Close CDP +- Spends CDP UTxO +- Burns all remaining PUSD debt +- Burns CDP NFT +- Returns all ADA collateral to owner +- Validates: tx signed by owner, debt fully repaid + +### Liquidate CDP +- Spends CDP UTxO +- Burns PUSD equal to the CDP's debt +- Burns CDP NFT +- Sends full ADA collateral to liquidator +- Validates: LTV > 90% (using Pyth oracle price), debt fully covered +- Anyone can liquidate (no owner signature required) + +## Smart Contract – Mint (PUSD + CDP NFT) + +The mint leg of the combined validator checks: + +**For PUSD (token name `"PUSD"`):** +- The corresponding CDP spend action is present in the same transaction +- The mint/burn amount is consistent with the CDP datum changes + +**For CDP NFT (unique token name):** +- Exactly 1 NFT minted on open (and the token name matches the derived unique ID) +- Exactly 1 NFT burned on close/liquidate + +## Pyth Oracle Integration + +Full on-chain Pyth Lazer verification implemented in Scalus. +Reference Aiken implementation: `pyth-network/pyth-lazer-cardano` (GitHub). +Reference JS SDK: `@pythnetwork/pyth-lazer-cardano-js` (in `pyth-crosschain` repo). + +### On-Chain Architecture + +The Pyth Lazer system uses a **withdrawal script pattern**: + +1. A **Pyth State UTxO** holds an NFT (token name `"Pyth State"` under `pyth_id` policy) with an + inline datum: + ``` + Pyth: + governance: Governance + trusted_signers: Pairs + deprecated_withdraw_scripts: Pairs + withdraw_script: ScriptHash + ``` +2. The **Pyth withdraw script** (identified by `withdraw_script` hash from the state datum) + verifies Ed25519 signatures on price updates and checks signers are in `trusted_signers`. +3. **Our CDP validator** reads the already-verified price updates from the withdrawal redeemer + (no need to re-verify signatures in our script). + +### Transaction Structure + +For any CDP action requiring a price (open with borrow, borrow, liquidate): + +1. Include the **Pyth State UTxO as a reference input** +2. Include a **zero-withdrawal** (`0 lovelace`) from the Pyth withdraw script +3. Pass the **signed price update bytes as the withdrawal redeemer** (`List`) +4. Include our CDP validator spend/mint in the same transaction +5. Set a **short validity window** (e.g., ±60 seconds) for price freshness + +### Signed Price Update Binary Format ("Solana" format) + +Fetched off-chain via `PythLazerClient` with `formats: ["solana"]`: + +| Offset | Size | Field | Description | +|--------|----------|-----------|------------------------------------------| +| 0 | 4 bytes | magic | `0xb9011a82` (LE) | +| 4 | 64 bytes | signature | Ed25519 signature over the payload | +| 68 | 32 bytes | key | Ed25519 public key of the signer | +| 100 | 2 bytes | size | U16 LE – payload length | +| 102 | `size` | payload | Price update payload | + +### Price Update Payload Format + +| Offset | Size | Field | Description | +|--------|----------|--------------|--------------------------------------| +| 0 | 4 bytes | magic | `0x75d3c793` (LE) | +| 4 | 8 bytes | timestamp_us | U64 LE – timestamp in microseconds | +| 12 | 1 byte | channel_id | U8 – channel identifier | +| 13 | 1 byte | feeds_len | U8 – number of feeds | +| 14+ | variable | feeds | Array of Feed structures | + +Each **Feed**: `feed_id` (U32 LE) + `properties_len` (U8) + properties. +Each **Property**: `property_id` (U8) + type-specific data: + +| ID | Name | Format | +|----|-------------------|--------------------------------| +| 0 | Price | I64 LE (0 = None) | +| 4 | Exponent | I16 LE | + +(Other properties: BestBidPrice(1), BestAskPrice(2), PublisherCount(3), Confidence(5), etc.) + +### On-Chain Price Reading (Scalus implementation) + +Our validator needs to: +1. Find the Pyth State UTxO in `tx.referenceInputs` by looking for the `"Pyth State"` NFT +2. Extract `withdraw_script` hash from the inline datum +3. Find the withdrawal redeemer for that script hash in `tx.redeemers` +4. Parse each `ByteArray` in the redeemer list as a signed message (skip magic+signature+key, read payload) +5. Parse the payload to extract feeds, find feed ID 16 (ADA/USD) +6. Extract `price` and `exponent`, compute: `ada_usd_price = price * 10^exponent` + +Note: **Signature verification is done by the Pyth withdraw script**, not by our validator. +We only parse the payload to extract the price. The Plutus V3 builtin `verifyEd25519Signature` +is used by the Pyth withdraw script. + +### Off-Chain Price Fetching + +The backend fetches prices using the Pyth Lazer SDK: +```typescript +const lazer = await PythLazerClient.create({ token: PYTH_KEY }); +const latestPrice = await lazer.getLatestPrice({ + channel: "fixed_rate@200ms", + formats: ["solana"], + jsonBinaryEncoding: "hex", + priceFeedIds: [16], // ADA/USD + properties: ["price", "exponent"], +}); +const update = Buffer.from(latestPrice.solana.data, "hex"); +``` + +The `PYTH_KEY` is the Pyth Lazer access token from `.env`. + +### Key Parameters +- **ADA/USD feed ID**: 16 +- **pyth_id**: Pyth deployment policy ID (network-specific, passed as validator parameter) +- **Pyth State NFT token name**: `"Pyth State"` (UTF-8 encoded) + +Required for: Open (if minting PUSD), Borrow, Liquidate – any action that needs current price. + +## Backend API + +REST API returning unsigned transaction CBOR (hex) for wallet signing. +Built with Tapir. Swagger UI at `/docs`. + +### Transaction Building Endpoints + +| Endpoint | Method | Description | +|-----------------------|--------|------------------------------------------------| +| `/cdp/open` | POST | Build tx: lock ADA, mint CDP NFT, optionally mint PUSD | +| `/cdp/borrow` | POST | Build tx: mint more PUSD against existing CDP | +| `/cdp/repay` | POST | Build tx: burn PUSD, reduce debt | +| `/cdp/close` | POST | Build tx: repay all, burn NFT, reclaim ADA | +| `/cdp/liquidate` | POST | Build tx: liquidate unhealthy CDP | + +All tx endpoints return unsigned transaction CBOR hex for the frontend wallet to sign and submit. + +### Query Endpoints + +| Endpoint | Method | Description | +|-----------------------|--------|------------------------------------------------| +| `/cdp/{nft-token-name}` | GET | CDP details: collateral, debt, LTV, health | +| `/cdps` | GET | List all CDPs (filterable by owner) | +| `/price` | GET | Current ADA/USD price from Pyth | + +## Tests + +Use Scalus Emulator to run tests. + +### Unit Tests +- Open CDP with collateral +- Borrow PUSD up to 95% LTV (should succeed) +- Borrow PUSD beyond 95% LTV (should fail) +- Repay PUSD +- Close CDP +- Liquidate CDP when LTV > 90% (should succeed) +- Liquidate CDP when LTV <= 90% (should fail) + +### Demo Scenario Test +1. Alice opens CDP with ADA collateral +2. Alice borrows PUSD, LTV is ~85% +3. Alice borrows more PUSD, pushing LTV above 90% +4. Bob liquidates Alice's CDP – receives ADA, burns PUSD +5. Verify Alice's CDP is gone, Bob has the ADA + +## Frontend + +Single-page React/TypeScript app. Minimalistic, clean design. +Located in `/frontend` directory (monorepo). + +### Tech Stack +- **React 19** + **TypeScript** + **Vite** +- **Tailwind CSS** – utility-first styling, minimalist aesthetic +- **cardano-connect** – CIP-30 wallet connection (Nami, Eternl, Lace, etc.) +- **react-query** – data fetching, polling, caching for CDP/price data + +### Layout + +Single page with a top bar and a main content area. No routing needed. + +``` +┌─────────────────────────────────────────────────┐ +│ Pythacoin ADA/USD: $0.42 [Connect] │ ← top bar +├─────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────────────────────────────────────┐ │ +│ │ Open CDP │ │ ← open CDP form +│ │ Collateral: [___] ADA Borrow: [___] PUSD │ │ (visible when wallet connected) +│ │ LTV preview: 82% [Open CDP] │ │ +│ └─────────────────────────────────────────────┘ │ +│ │ +│ Your CDPs │ ← user's CDPs section +│ ┌─────────────────────────────────────────────┐ │ +│ │ CDP #a1b2 │ 500 ADA │ 180 PUSD │ LTV 85% │ │ +│ │ [Borrow] [Repay] [Close] │ │ +│ └─────────────────────────────────────────────┘ │ +│ │ +│ All CDPs │ ← global CDP list +│ ┌─────────────────────────────────────────────┐ │ +│ │ Owner │ Collateral │ Debt │ LTV │ │ │ +│ │ addr1... │ 500 ADA │ 420 │ 92% │ [L] │ │ ← [L] = Liquidate button +│ │ addr1... │ 1000 ADA │ 300 │ 71% │ │ │ (shown when LTV > 90%) +│ └─────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────┘ +``` + +### Top Bar +- **Logo/title**: "Pythacoin" +- **ADA/USD price**: live price from `/price` endpoint, auto-refreshes +- **Connect wallet** button: uses cardano-connect for CIP-30 wallet selection +- Shows connected wallet address (truncated) when connected + +### Open CDP Form +- Visible only when wallet is connected +- Inputs: collateral amount (ADA), borrow amount (PUSD) +- Live LTV preview calculated from inputs and current price +- LTV color: green (<70%), yellow (70-85%), orange (85-90%), red (>90%) +- "Open CDP" button: calls `POST /cdp/open`, gets unsigned tx, sends to wallet for signing + +### Your CDPs Section +- Visible only when wallet is connected +- Shows CDPs owned by connected wallet (filtered from `/cdps?owner=...`) +- Each CDP card shows: NFT ID (short), collateral (ADA), debt (PUSD), current LTV +- LTV color coding same as above +- Action buttons per CDP: + - **Borrow**: dialog/inline input for additional PUSD amount, shows new LTV preview + - **Repay**: dialog/inline input for PUSD amount to repay, shows new LTV preview + - **Close**: confirms, then builds close tx (must have enough PUSD to cover debt) + +### All CDPs Table +- Always visible (even without wallet) +- Lists all CDPs from `/cdps` endpoint +- Columns: owner (truncated address), collateral, debt, LTV +- **Liquidate** button shown on CDPs with LTV > 90% +- Liquidate: builds liquidate tx, sends to wallet (liquidator must hold enough PUSD) +- Auto-refreshes every ~10 seconds + +### Interaction Flow +1. User clicks "Connect Wallet" → cardano-connect modal → selects wallet +2. App reads wallet address, fetches user's CDPs and all CDPs +3. User fills Open CDP form → clicks "Open CDP" +4. Frontend calls `POST /cdp/open` with params → backend returns unsigned tx CBOR hex +5. Frontend submits CBOR to wallet via CIP-30 `signTx()` → wallet prompts user +6. User signs → frontend submits signed tx via CIP-30 `submitTx()` +7. UI refreshes CDP list after confirmation + +### Design Notes +- Dark theme, monospace accents for numbers/hashes +- Muted color palette with accent color for CTAs +- Subtle card borders, generous whitespace +- No unnecessary animations – clean, functional DeFi aesthetic +- Responsive: works on desktop, passable on mobile + +## Agents Workflow + +Multiple Claude Code agents work in parallel on different components. +The orchestrator agent coordinates work, resolves cross-cutting concerns, and merges results. + +### Agent Topology + +``` +Orchestrator (main Claude Code session) + ├── Agent 1: Smart Contract ← on-chain Scalus code + ├── Agent 2: Backend API ← off-chain tx building, REST API + ├── Agent 3: Tests ← emulator tests + └── Agent 4: Frontend ← React/TypeScript UI +``` + +### Agent 1: Smart Contract + +**Scope**: `src/main/scala/pythacoin/` – on-chain validators + +**Tasks**: +1. Rename package from `starter` to `pythacoin` +2. Define on-chain data types: `CdpDatum`, `CdpRedeemer` (Open/Borrow/Repay/Close/Liquidate) +3. Implement Pyth price parsing in Scalus (port from Aiken `pyth-lazer-cardano`): + - Parse signed message envelope (skip magic+sig+key, extract payload) + - Parse price update payload (magic, timestamp, feeds) + - Extract ADA/USD price (feed ID 16) with exponent +4. Implement combined validator `@Compile object CdpValidator`: + - `spend`: validate CDP actions (open, borrow, repay, close, liquidate) + - `mint`: validate PUSD minting/burning and CDP NFT minting/burning +5. Implement LTV checks (95% max mint, 90% liquidation threshold) +6. Off-chain contract compilation (`CdpContract` object, like `MintingPolicyContract`) + +**Depends on**: Nothing (can start immediately) +**Blocks**: Agent 2, Agent 3 + +### Agent 2: Backend API + +**Scope**: `src/main/scala/pythacoin/` – off-chain tx building, REST server + +**Tasks**: +1. Update `AppCtx` for CDP context (Pyth config, CDP script, PUSD policy ID) +2. Add Pyth price fetching (use `PythLazerClient` via HTTP/WebSocket, `PYTH_KEY` from `.env`) +3. Build transaction constructors for each CDP action: + - `openCdp(collateralAda, borrowPusd, ownerAddr)` → unsigned tx CBOR + - `borrowPusd(cdpNftId, amount, ownerAddr)` → unsigned tx CBOR + - `repayPusd(cdpNftId, amount, ownerAddr)` → unsigned tx CBOR + - `closeCdp(cdpNftId, ownerAddr)` → unsigned tx CBOR + - `liquidateCdp(cdpNftId, liquidatorAddr)` → unsigned tx CBOR +4. Each tx builder: fetches current Pyth price, includes Pyth reference input + zero-withdrawal +5. Implement query functions: list CDPs (scan script UTxOs), get CDP by NFT, get price +6. Define Tapir REST endpoints (POST for tx building, GET for queries) +7. Update `Server` and `Main` entry points + +**Depends on**: Agent 1 (needs compiled validator, data types) +**Blocks**: Agent 4 + +### Agent 3: Tests + +**Scope**: `src/test/scala/pythacoin/` + +**Tasks**: +1. Set up emulator test base with mock Pyth oracle: + - Create a mock Pyth State UTxO with test trusted signers + - Generate signed price updates with known test keys + - Set up the mock Pyth withdraw script (or use a simple always-succeeds for testing) +2. Unit tests for each CDP action: + - Open CDP with collateral (success) + - Open CDP and borrow PUSD at 85% LTV (success) + - Borrow up to 95% LTV (success) + - Borrow beyond 95% LTV (failure) + - Repay PUSD (success) + - Close CDP – full repayment (success) + - Liquidate when LTV > 90% (success) + - Liquidate when LTV <= 90% (failure) +3. Demo scenario test (Alice & Bob) +4. Edge cases: zero borrow, exact threshold values, multiple CDPs + +**Depends on**: Agent 1 (needs compiled validator) +**Can run in parallel with**: Agent 2 + +### Agent 4: Frontend + +**Scope**: `frontend/` + +**Tasks**: +1. Scaffold Vite + React 19 + TypeScript project +2. Install and configure Tailwind CSS (dark theme) +3. Set up cardano-connect for CIP-30 wallet integration +4. Set up react-query for data fetching with polling +5. Implement API client (typed fetch wrappers for all backend endpoints) +6. Build components: + - `TopBar` – logo, price display, wallet connect button + - `OpenCdpForm` – collateral/borrow inputs, LTV preview + - `YourCdps` – user's CDP cards with action buttons + - `AllCdpsTable` – global CDP list with liquidate buttons + - `LtvBadge` – color-coded LTV display +7. Wire up transaction flow: API call → CIP-30 `signTx` → `submitTx` → refresh +8. Styling: dark theme, monospace numbers, clean DeFi aesthetic + +**Depends on**: Agent 2 (needs API contract/types for the client) +**Can start early**: scaffolding, component shells, mock data + +### Execution Plan + +**Phase 1** (parallel): +- Agent 1 starts smart contract development +- Agent 4 starts frontend scaffolding with mock data + +**Phase 2** (after Agent 1 delivers compiled validator): +- Agent 2 starts backend API +- Agent 3 starts emulator tests +- Agent 4 continues with component development + +**Phase 3** (after Agent 2 delivers API): +- Agent 4 wires up real API calls +- Agent 3 adds integration tests + +**Phase 4** (all agents): +- Integration testing end-to-end +- Bug fixes and polish diff --git a/lazer/cardano/pythacoin/build.sbt b/lazer/cardano/pythacoin/build.sbt new file mode 100644 index 00000000..e9190c40 --- /dev/null +++ b/lazer/cardano/pythacoin/build.sbt @@ -0,0 +1,55 @@ +val scalusVersion = "0.16.0" +val scalusPluginVersion = scalusVersion + +resolvers += Resolver.sonatypeCentralSnapshots + +// Latest Scala 3 LTS version +ThisBuild / scalaVersion := "3.3.7" + +ThisBuild / scalacOptions ++= Seq("-feature", "-deprecation", "-unchecked") + +// Add the Scalus compiler plugin +addCompilerPlugin("org.scalus" %% "scalus-plugin" % scalusPluginVersion) + +// Main application +lazy val core = (project in file(".")) + .settings( + libraryDependencies ++= Seq( + // Scalus + "org.scalus" %% "scalus" % scalusVersion, + "org.scalus" %% "scalus-cardano-ledger" % scalusVersion, + // Tapir for API definition + "com.softwaremill.sttp.tapir" %% "tapir-netty-server-sync" % "1.13.13", + "com.softwaremill.sttp.tapir" %% "tapir-swagger-ui-bundle" % "1.13.13", + "com.softwaremill.sttp.tapir" %% "tapir-json-upickle" % "1.13.13", + // Argument parsing + "com.monovore" %% "decline" % "2.6.1", + "org.slf4j" % "slf4j-simple" % "2.0.17" + ), + run / fork := true, + libraryDependencies ++= Seq( + "org.scalus" %% "scalus-testkit" % scalusVersion, + "org.scalatest" %% "scalatest" % "3.2.19" % Test, + "org.scalatestplus" %% "scalacheck-1-18" % "3.2.19.0" % Test, + "org.scalacheck" %% "scalacheck" % "1.19.0" % Test + ) + ) + +// Integration tests +lazy val integration = (project in file("integration")) + .dependsOn(core % "compile->compile;test->test") + .settings( + publish / skip := true, + // test dependencies + libraryDependencies ++= Seq( + "org.scalus" %% "scalus-testkit" % scalusVersion, + "org.scalatest" %% "scalatest" % "3.2.19" % Test, + "org.scalatestplus" %% "scalacheck-1-18" % "3.2.19.0" % Test, + "org.scalacheck" %% "scalacheck" % "1.19.0" % Test, + // Testcontainers for integration testing + "com.dimafeng" %% "testcontainers-scala-core" % "0.44.1" % Test, + "com.dimafeng" %% "testcontainers-scala-scalatest" % "0.44.1" % Test, + // Yaci DevKit for Cardano local devnet + "com.bloxbean.cardano" % "yaci-cardano-test" % "0.1.0" % Test + ) + ) diff --git a/lazer/cardano/pythacoin/flake.lock b/lazer/cardano/pythacoin/flake.lock new file mode 100644 index 00000000..a0727f62 --- /dev/null +++ b/lazer/cardano/pythacoin/flake.lock @@ -0,0 +1,61 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1767047869, + "narHash": "sha256-tzYsEzXEVa7op1LTnrLSiPGrcCY6948iD0EcNLWcmzo=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "89dbf01df72eb5ebe3b24a86334b12c27d68016a", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-25.11", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/lazer/cardano/pythacoin/flake.nix b/lazer/cardano/pythacoin/flake.nix new file mode 100644 index 00000000..9a5cd8bf --- /dev/null +++ b/lazer/cardano/pythacoin/flake.nix @@ -0,0 +1,50 @@ +{ + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = + { self + , flake-utils + , nixpkgs + , ... + } @ inputs: + (flake-utils.lib.eachDefaultSystem (system: + let + pkgs = import nixpkgs { inherit system; }; + jdk = pkgs.openjdk25; + sbt = pkgs.sbt.override { jre = jdk; }; + in + rec { + devShell = pkgs.mkShell { + # This fixes bash prompt/autocomplete issues with subshells (i.e. in VSCode) under `nix develop`/direnv + buildInputs = [ pkgs.bashInteractive ]; + packages = with pkgs; [ + git + jdk + gh + sbt + ]; + }; + }) + ); + + nixConfig = { + extra-substituters = [ + "https://cache.iog.io" + "https://iohk.cachix.org" + "https://cache.nixos.org/" + "https://nix-community.cachix.org" + ]; + extra-trusted-public-keys = [ + "hydra.iohk.io:f/Ea+s+dFdN+3Y/G+FDgSq+a5NEWhJGzdjvKNGv0/EQ=" + "iohk.cachix.org-1:DpRUyj7h7V830dp/i6Nti+NEO2/nhblbov/8MW7Rqoo=" + "cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY=" + "nix-community.cachix.org-1:mB9FSh9qf2dCimDSUo8Zy7bkq5CX+/rkCWyvRCYg3Fs=" + ]; + allow-import-from-derivation = true; + experimental-features = [ "nix-command" "flakes" ]; + accept-flake-config = true; + }; +} diff --git a/lazer/cardano/pythacoin/frontend/index.html b/lazer/cardano/pythacoin/frontend/index.html new file mode 100644 index 00000000..49b08c2f --- /dev/null +++ b/lazer/cardano/pythacoin/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + Pythacoin - CDP Stablecoin + + +
+ + + diff --git a/lazer/cardano/pythacoin/frontend/package-lock.json b/lazer/cardano/pythacoin/frontend/package-lock.json new file mode 100644 index 00000000..477a1b8c --- /dev/null +++ b/lazer/cardano/pythacoin/frontend/package-lock.json @@ -0,0 +1,2902 @@ +{ + "name": "pythacoin-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "pythacoin-frontend", + "version": "0.1.0", + "dependencies": { + "@tanstack/react-query": "^5.60.0", + "cbor-x": "^1.6.4", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.3.0", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.47", + "tailwindcss": "^3.4.14", + "typescript": "^5.6.0", + "vite": "^6.0.0" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@cbor-extract/cbor-extract-darwin-arm64": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-darwin-arm64/-/cbor-extract-darwin-arm64-2.2.2.tgz", + "integrity": "sha512-ZKZ/F8US7JR92J4DMct6cLW/Y66o2K576+zjlEN/MevH70bFIsB10wkZEQPLzl2oNh2SMGy55xpJ9JoBRl5DOA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@cbor-extract/cbor-extract-darwin-x64": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-darwin-x64/-/cbor-extract-darwin-x64-2.2.2.tgz", + "integrity": "sha512-32b1mgc+P61Js+KW9VZv/c+xRw5EfmOcPx990JbCBSkYJFY0l25VinvyyWfl+3KjibQmAcYwmyzKF9J4DyKP/Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@cbor-extract/cbor-extract-linux-arm": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-linux-arm/-/cbor-extract-linux-arm-2.2.2.tgz", + "integrity": "sha512-tNg0za41TpQfkhWjptD+0gSD2fggMiDCSacuIeELyb2xZhr7PrhPe5h66Jc67B/5dmpIhI2QOUtv4SBsricyYQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@cbor-extract/cbor-extract-linux-arm64": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-linux-arm64/-/cbor-extract-linux-arm64-2.2.2.tgz", + "integrity": "sha512-wfqgzqCAy/Vn8i6WVIh7qZd0DdBFaWBjPdB6ma+Wihcjv0gHqD/mw3ouVv7kbbUNrab6dKEx/w3xQZEdeXIlzg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@cbor-extract/cbor-extract-linux-x64": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-linux-x64/-/cbor-extract-linux-x64-2.2.2.tgz", + "integrity": "sha512-rpiLnVEsqtPJ+mXTdx1rfz4RtUGYIUg2rUAZgd1KjiC1SehYUSkJN7Yh+aVfSjvCGtVP0/bfkQkXpPXKbmSUaA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@cbor-extract/cbor-extract-win32-x64": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-win32-x64/-/cbor-extract-win32-x64-2.2.2.tgz", + "integrity": "sha512-dI+9P7cfWxkTQ+oE+7Aa6onEn92PHgfWXZivjNheCRmTBDBf2fx6RyTi0cmgpYLnD1KLZK9ZYrMxaPZ4oiXhGA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.0.tgz", + "integrity": "sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.0.tgz", + "integrity": "sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.0.tgz", + "integrity": "sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.0.tgz", + "integrity": "sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.0.tgz", + "integrity": "sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.0.tgz", + "integrity": "sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.0.tgz", + "integrity": "sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.0.tgz", + "integrity": "sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.0.tgz", + "integrity": "sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.0.tgz", + "integrity": "sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.0.tgz", + "integrity": "sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.0.tgz", + "integrity": "sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.0.tgz", + "integrity": "sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.0.tgz", + "integrity": "sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.0.tgz", + "integrity": "sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.0.tgz", + "integrity": "sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.0.tgz", + "integrity": "sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.0.tgz", + "integrity": "sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.0.tgz", + "integrity": "sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.0.tgz", + "integrity": "sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.0.tgz", + "integrity": "sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.0.tgz", + "integrity": "sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.0.tgz", + "integrity": "sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.0.tgz", + "integrity": "sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.0.tgz", + "integrity": "sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tanstack/query-core": { + "version": "5.94.5", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.94.5.tgz", + "integrity": "sha512-Vx1JJiBURW/wdNGP45afjrqn0LfxYwL7K/bSrQvNRtyLGF1bxQPgUXCpzscG29e+UeFOh9hz1KOVala0N+bZiA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.94.5", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.94.5.tgz", + "integrity": "sha512-1wmrxKFkor+q8l+ygdHmv0Sq5g84Q3p4xvuJ7AdSIAhQQ7udOt+ZSZ19g1Jea3mHqtlTslLGJsmC4vHFgP0P3A==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.94.5" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.4.27", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz", + "integrity": "sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.1", + "caniuse-lite": "^1.0.30001774", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.10", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.10.tgz", + "integrity": "sha512-sUoJ3IMxx4AyRqO4MLeHlnGDkyXRoUG0/AI9fjK+vS72ekpV0yWVY7O0BVjmBcRtkNcsAO2QDZ4tdKKGoI6YaQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001780", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001780.tgz", + "integrity": "sha512-llngX0E7nQci5BPJDqoZSbuZ5Bcs9F5db7EtgfwBerX9XGtkkiO4NwfDDIRzHTTwcYC8vC7bmeUEPGrKlR/TkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/cbor-extract": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/cbor-extract/-/cbor-extract-2.2.2.tgz", + "integrity": "sha512-hlSxxI9XO2yQfe9g6msd3g4xCfDqK5T5P0fRMLuaLHhxn4ViPrm+a+MUfhrvH2W962RGxcBwEGzLQyjbDG1gng==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.1.1" + }, + "bin": { + "download-cbor-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@cbor-extract/cbor-extract-darwin-arm64": "2.2.2", + "@cbor-extract/cbor-extract-darwin-x64": "2.2.2", + "@cbor-extract/cbor-extract-linux-arm": "2.2.2", + "@cbor-extract/cbor-extract-linux-arm64": "2.2.2", + "@cbor-extract/cbor-extract-linux-x64": "2.2.2", + "@cbor-extract/cbor-extract-win32-x64": "2.2.2" + } + }, + "node_modules/cbor-x": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/cbor-x/-/cbor-x-1.6.4.tgz", + "integrity": "sha512-UGKHjp6RHC6QuZ2yy5LCKm7MojM4716DwoSaqwQpaH4DvZvbBTGcoDNTiG9Y2lByXZYFEs9WRkS5tLl96IrF1Q==", + "license": "MIT", + "optionalDependencies": { + "cbor-extract": "^2.2.2" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.321", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.321.tgz", + "integrity": "sha512-L2C7Q279W2D/J4PLZLk7sebOILDSWos7bMsMNN06rK482umHUrh/3lM8G7IlHFOYip2oAg5nha1rCMxr/rs6ZQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.1.1.tgz", + "integrity": "sha512-+P72GAjVAbTxjjwUmwjVrqrdZROD4nf8KgpBoDxqXXTiYZZt/ud60dE5yvCSr9lRO8e8yv6kgJIC0K0PfZFVQw==", + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.1" + }, + "bin": { + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, + "node_modules/node-releases": { + "version": "2.0.36", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.0.tgz", + "integrity": "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.0", + "@rollup/rollup-android-arm64": "4.60.0", + "@rollup/rollup-darwin-arm64": "4.60.0", + "@rollup/rollup-darwin-x64": "4.60.0", + "@rollup/rollup-freebsd-arm64": "4.60.0", + "@rollup/rollup-freebsd-x64": "4.60.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.0", + "@rollup/rollup-linux-arm-musleabihf": "4.60.0", + "@rollup/rollup-linux-arm64-gnu": "4.60.0", + "@rollup/rollup-linux-arm64-musl": "4.60.0", + "@rollup/rollup-linux-loong64-gnu": "4.60.0", + "@rollup/rollup-linux-loong64-musl": "4.60.0", + "@rollup/rollup-linux-ppc64-gnu": "4.60.0", + "@rollup/rollup-linux-ppc64-musl": "4.60.0", + "@rollup/rollup-linux-riscv64-gnu": "4.60.0", + "@rollup/rollup-linux-riscv64-musl": "4.60.0", + "@rollup/rollup-linux-s390x-gnu": "4.60.0", + "@rollup/rollup-linux-x64-gnu": "4.60.0", + "@rollup/rollup-linux-x64-musl": "4.60.0", + "@rollup/rollup-openbsd-x64": "4.60.0", + "@rollup/rollup-openharmony-arm64": "4.60.0", + "@rollup/rollup-win32-arm64-msvc": "4.60.0", + "@rollup/rollup-win32-ia32-msvc": "4.60.0", + "@rollup/rollup-win32-x64-gnu": "4.60.0", + "@rollup/rollup-win32-x64-msvc": "4.60.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/lazer/cardano/pythacoin/frontend/package.json b/lazer/cardano/pythacoin/frontend/package.json new file mode 100644 index 00000000..974de0f2 --- /dev/null +++ b/lazer/cardano/pythacoin/frontend/package.json @@ -0,0 +1,27 @@ +{ + "name": "pythacoin-frontend", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@tanstack/react-query": "^5.60.0", + "cbor-x": "^1.6.4", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.3.0", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.47", + "tailwindcss": "^3.4.14", + "typescript": "^5.6.0", + "vite": "^6.0.0" + } +} diff --git a/lazer/cardano/pythacoin/frontend/postcss.config.js b/lazer/cardano/pythacoin/frontend/postcss.config.js new file mode 100644 index 00000000..2aa7205d --- /dev/null +++ b/lazer/cardano/pythacoin/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/lazer/cardano/pythacoin/frontend/src/App.tsx b/lazer/cardano/pythacoin/frontend/src/App.tsx new file mode 100644 index 00000000..deb35b3d --- /dev/null +++ b/lazer/cardano/pythacoin/frontend/src/App.tsx @@ -0,0 +1,251 @@ +import { useCallback, useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { TopBar } from "./components/TopBar"; +import { OpenCdpForm } from "./components/OpenCdpForm"; +import { AllCdpsTable } from "./components/AllCdpsTable"; +import { ActionModal } from "./components/ActionModal"; +import { useCdps } from "./hooks/useCdps"; +import { usePrice } from "./hooks/usePrice"; +import { useWallet } from "./hooks/useWallet"; +import { api } from "./api/client"; +import type { CdpInfo } from "./api/types"; + +type CdpAction = "borrow" | "repay" | "close" | "liquidate"; + +function lovelaceToAda(l: number): string { + return (l / 1_000_000).toFixed(2); +} + +function pusdToDisplay(p: number): string { + return (p / 1_000_000).toFixed(2); +} + +export default function App() { + const { data: cdps } = useCdps(); + const { data: price } = usePrice(); + const wallet = useWallet(); + const qc = useQueryClient(); + + const [activeCdp, setActiveCdp] = useState(null); + const [activeAction, setActiveAction] = useState(null); + const [actionLoading, setActionLoading] = useState(false); + const [actionError, setActionError] = useState(null); + + const handleConnect = useCallback(async () => { + const wallets = window.cardano ? Object.keys(window.cardano) : []; + const name = wallets.find((w) => w !== "ccvault") ?? wallets[0]; + if (!name) { + alert("No CIP-30 wallet found. Install Nami, Eternl, or Lace."); + return; + } + await wallet.connect(name); + }, [wallet]); + + const signAndSubmit = useCallback( + async (txHex: string) => { + if (!wallet.walletApi) throw new Error("Wallet not connected"); + console.log("[signAndSubmit] Signing tx, CBOR hex length:", txHex.length); + // CIP-30 signTx returns a TransactionWitnessSet CBOR, not the full signed tx + const witnessSetHex = await wallet.walletApi.signTx(txHex, true); + console.log("[signAndSubmit] Got witness set, length:", witnessSetHex?.length, "type:", typeof witnessSetHex); + console.log("[signAndSubmit] Witness set hex (first 80):", witnessSetHex?.substring(0, 80)); + // Send both to backend for merging + submission via Blockfrost + const result = await api.submitTx({ + txCborHex: txHex, + witnessCborHex: witnessSetHex, + }); + console.log("[signAndSubmit] Submitted! txHash:", result.txHash); + return result.txHash; + }, + [wallet.walletApi], + ); + + const refresh = useCallback(() => { + qc.invalidateQueries({ queryKey: ["cdps"] }); + wallet.refreshBalance(); + }, [qc, wallet]); + + const handleAction = useCallback( + (cdp: CdpInfo, action: CdpAction) => { + if (!wallet.address) { + alert("Connect your wallet first."); + return; + } + setActiveCdp(cdp); + setActiveAction(action); + setActionError(null); + }, + [wallet.address], + ); + + const handleConfirm = useCallback( + async (amount?: number) => { + if (!activeCdp || !activeAction || !wallet.address) return; + setActionLoading(true); + setActionError(null); + try { + let resp; + switch (activeAction) { + case "close": + resp = await api.close({ + nftName: activeCdp.nftName, + ownerAddress: wallet.address, + }); + break; + case "borrow": + if (!amount || amount <= 0) throw new Error("Enter a valid amount"); + resp = await api.borrow({ + nftName: activeCdp.nftName, + amount: amount, + ownerAddress: wallet.address, + }); + break; + case "repay": + if (!amount || amount <= 0) throw new Error("Enter a valid amount"); + resp = await api.repay({ + nftName: activeCdp.nftName, + amount: amount, + ownerAddress: wallet.address, + }); + break; + case "liquidate": + resp = await api.liquidate({ + nftName: activeCdp.nftName, + liquidatorAddress: wallet.address, + }); + break; + } + const txHash = await signAndSubmit(resp.txCborHex); + console.log(`[${activeAction}] Submitted! txHash:`, txHash); + setActiveCdp(null); + setActiveAction(null); + refresh(); + } catch (err) { + console.error(`[${activeAction}] Error:`, err); + setActionError(err instanceof Error ? err.message : "Failed"); + } finally { + setActionLoading(false); + } + }, + [activeCdp, activeAction, wallet.address, signAndSubmit, refresh], + ); + + const handleCancel = useCallback(() => { + setActiveCdp(null); + setActiveAction(null); + setActionError(null); + }, []); + + const modalProps = activeCdp && activeAction ? getModalProps(activeAction, activeCdp) : null; + + return ( +
+ +
+

+ PUSD is a synthetic stablecoin on Cardano, backed by ADA collateral and priced via the{" "} + + Pyth Network + {" "} + oracle. +

+ {wallet.connected && wallet.address && ( + + )} +
+

All CDPs

+ +
+ +
+ + + How it works + +
    +
  • Open a CDP — lock ADA as collateral and mint PUSD. Max loan-to-value: 95%.
  • +
  • Borrow more — mint additional PUSD against your collateral (owner only, must stay under 95% LTV).
  • +
  • Repay — burn PUSD to reduce your debt (owner only).
  • +
  • Close — burn all PUSD debt + the CDP NFT, get your ADA back (owner only).
  • +
  • Liquidate — anyone can liquidate a CDP whose LTV exceeds 90%. The liquidator provides PUSD to cover the debt and receives the ADA collateral.
  • +
+

+ All rules are enforced on-chain by a Plutus V3 smart contract. Each CDP is identified by a unique NFT. +

+
+
+ + + {modalProps && ( + + )} +
+ ); +} + +function getModalProps(action: CdpAction, cdp: CdpInfo) { + const ada = lovelaceToAda(cdp.collateralLovelace); + const pusd = pusdToDisplay(cdp.debtPusd); + switch (action) { + case "close": + return { + title: "Close CDP", + description: `Burn ${pusd} PUSD debt and return ${ada} ADA collateral.`, + confirmLabel: "Close CDP", + confirmColor: "red" as const, + }; + case "borrow": + return { + title: "Borrow PUSD", + description: `Current debt: ${pusd} PUSD. Collateral: ${ada} ADA.`, + amountLabel: "Additional PUSD to borrow", + confirmLabel: "Borrow", + confirmColor: "purple" as const, + }; + case "repay": + return { + title: "Repay PUSD", + description: `Current debt: ${pusd} PUSD. Collateral: ${ada} ADA.`, + amountLabel: "PUSD to repay", + confirmLabel: "Repay", + confirmColor: "green" as const, + }; + case "liquidate": + return { + title: "Liquidate CDP", + description: `Liquidate this under-collateralized CDP (${ada} ADA / ${pusd} PUSD).`, + confirmLabel: "Liquidate", + confirmColor: "red" as const, + }; + } +} diff --git a/lazer/cardano/pythacoin/frontend/src/api/client.ts b/lazer/cardano/pythacoin/frontend/src/api/client.ts new file mode 100644 index 00000000..b76f203a --- /dev/null +++ b/lazer/cardano/pythacoin/frontend/src/api/client.ts @@ -0,0 +1,57 @@ +import type { + CdpInfo, + PriceInfo, + OpenCdpRequest, + BorrowRequest, + RepayRequest, + CloseRequest, + LiquidateRequest, + TxResponse, + SubmitTxRequest, + SubmitTxResponse, +} from "./types"; + +const BASE = "/api"; + +async function get(path: string): Promise { + console.log(`[API] GET ${path}`); + const res = await fetch(`${BASE}${path}`); + if (!res.ok) { + const errText = await res.text(); + console.error(`[API] GET ${path} failed (${res.status}):`, errText); + throw new Error(errText); + } + const data = await res.json(); + if (path !== "/price") console.log(`[API] GET ${path} response:`, data); + return data; +} + +async function post(path: string, body: unknown): Promise { + console.log(`[API] POST ${path}`, body); + const res = await fetch(`${BASE}${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const errText = await res.text(); + console.error(`[API] POST ${path} failed (${res.status}):`, errText); + throw new Error(errText); + } + const data = await res.json(); + console.log(`[API] POST ${path} response:`, data); + return data; +} + +export const api = { + getPrice: () => get("/price"), + listCdps: () => get("/cdps"), + openCdp: (req: OpenCdpRequest) => post("/cdp/open", req), + borrow: (req: BorrowRequest) => post("/cdp/borrow", req), + repay: (req: RepayRequest) => post("/cdp/repay", req), + close: (req: CloseRequest) => post("/cdp/close", req), + liquidate: (req: LiquidateRequest) => + post("/cdp/liquidate", req), + submitTx: (req: SubmitTxRequest) => + post("/tx/submit", req), +}; diff --git a/lazer/cardano/pythacoin/frontend/src/api/types.ts b/lazer/cardano/pythacoin/frontend/src/api/types.ts new file mode 100644 index 00000000..bcd5bc8c --- /dev/null +++ b/lazer/cardano/pythacoin/frontend/src/api/types.ts @@ -0,0 +1,54 @@ +export interface CdpInfo { + nftName: string; + owner: string; + collateralLovelace: number; + debtPusd: number; + ltv: number; +} + +export interface PriceInfo { + adaUsd: number; + timestamp: string; + policyId: string; +} + +export interface OpenCdpRequest { + collateralAda: number; + borrowPusd: number; + ownerAddress: string; +} + +export interface BorrowRequest { + nftName: string; + amount: number; + ownerAddress: string; +} + +export interface RepayRequest { + nftName: string; + amount: number; + ownerAddress: string; +} + +export interface CloseRequest { + nftName: string; + ownerAddress: string; +} + +export interface LiquidateRequest { + nftName: string; + liquidatorAddress: string; +} + +export interface TxResponse { + txCborHex: string; +} + +export interface SubmitTxRequest { + txCborHex: string; + witnessCborHex: string; +} + +export interface SubmitTxResponse { + txHash: string; +} diff --git a/lazer/cardano/pythacoin/frontend/src/components/ActionModal.tsx b/lazer/cardano/pythacoin/frontend/src/components/ActionModal.tsx new file mode 100644 index 00000000..b8e7b66a --- /dev/null +++ b/lazer/cardano/pythacoin/frontend/src/components/ActionModal.tsx @@ -0,0 +1,77 @@ +import { useState } from "react"; + +interface Props { + title: string; + description: string; + amountLabel?: string; + confirmLabel: string; + confirmColor: "red" | "purple" | "green"; + loading: boolean; + error: string | null; + onConfirm: (amount?: number) => void; + onCancel: () => void; +} + +export function ActionModal({ + title, + description, + amountLabel, + confirmLabel, + confirmColor, + loading, + error, + onConfirm, + onCancel, +}: Props) { + const [amount, setAmount] = useState(""); + + const colorClasses = { + red: "bg-red-600 hover:bg-red-500", + purple: "bg-pyth-purple hover:opacity-90", + green: "bg-green-600 hover:bg-green-500", + }; + + return ( +
+
+

{title}

+

{description}

+ {amountLabel && ( +
+ + setAmount(e.target.value)} + className="w-full bg-pyth-dark border border-pyth-border rounded px-3 py-2 text-sm" + autoFocus + required + /> +
+ )} + {error &&

{error}

} +
+ + +
+
+
+ ); +} diff --git a/lazer/cardano/pythacoin/frontend/src/components/AllCdpsTable.tsx b/lazer/cardano/pythacoin/frontend/src/components/AllCdpsTable.tsx new file mode 100644 index 00000000..ef6ec665 --- /dev/null +++ b/lazer/cardano/pythacoin/frontend/src/components/AllCdpsTable.tsx @@ -0,0 +1,44 @@ +import type { CdpInfo } from "../api/types"; +import { CdpCard } from "./CdpCard"; + +interface Props { + cdps: CdpInfo[]; + adaUsd: number | null; + ownerAddress: string | null; + onAction?: (cdp: CdpInfo, action: "borrow" | "repay" | "close" | "liquidate") => void; +} + +/** Extract payment key hash (28 bytes = 56 hex chars) from CIP-30 hex address. */ +function extractPkh(address: string): string { + // Shelley base address: [1 header byte][28 payment hash][28 stake hash] + return address.slice(2, 58); +} + +export function AllCdpsTable({ cdps, adaUsd, ownerAddress, onAction }: Props) { + if (cdps.length === 0) { + return ( +
+ No CDPs found. Open the first one! +
+ ); + } + + const myPkh = ownerAddress ? extractPkh(ownerAddress) : null; + + return ( +
+ {cdps.map((cdp) => ( + onAction?.(cdp, "borrow")} + onRepay={() => onAction?.(cdp, "repay")} + onClose={() => onAction?.(cdp, "close")} + onLiquidate={() => onAction?.(cdp, "liquidate")} + /> + ))} +
+ ); +} diff --git a/lazer/cardano/pythacoin/frontend/src/components/CdpCard.tsx b/lazer/cardano/pythacoin/frontend/src/components/CdpCard.tsx new file mode 100644 index 00000000..9dc4f7e1 --- /dev/null +++ b/lazer/cardano/pythacoin/frontend/src/components/CdpCard.tsx @@ -0,0 +1,100 @@ +import type { CdpInfo } from "../api/types"; +import { LtvBadge } from "./LtvBadge"; + +interface Props { + cdp: CdpInfo; + adaUsd: number | null; + isOwner: boolean; + onBorrow?: () => void; + onRepay?: () => void; + onClose?: () => void; + onLiquidate?: () => void; +} + +function lovelaceToAda(l: number): string { + return (l / 1_000_000).toFixed(2); +} + +function pusdToDisplay(p: number): string { + return (p / 1_000_000).toFixed(2); +} + +function hexToUtf8(hex: string): string { + const bytes = new Uint8Array(hex.length / 2); + for (let i = 0; i < hex.length; i += 2) { + bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16); + } + return new TextDecoder().decode(bytes); +} + +function computeLtv(cdp: CdpInfo, adaUsd: number | null): number { + if (!adaUsd || adaUsd <= 0) return 0; + const debtUsd = cdp.debtPusd / 1_000_000; + const collateralUsd = (cdp.collateralLovelace / 1_000_000) * adaUsd; + if (collateralUsd <= 0) return 0; + return (debtUsd / collateralUsd) * 100; +} + +export function CdpCard({ + cdp, + adaUsd, + isOwner, + onBorrow, + onRepay, + onClose, + onLiquidate, +}: Props) { + const ltv = computeLtv(cdp, adaUsd); + return ( +
+
+ {hexToUtf8(cdp.nftName)} + +
+
+
+
Collateral
+
+ {lovelaceToAda(cdp.collateralLovelace)} ADA +
+
+
+
Debt
+
{pusdToDisplay(cdp.debtPusd)} PUSD
+
+
+
+ {isOwner && ( + <> + + + + + )} + {!isOwner && ltv > 90 && ( + + )} +
+
+ ); +} diff --git a/lazer/cardano/pythacoin/frontend/src/components/LtvBadge.tsx b/lazer/cardano/pythacoin/frontend/src/components/LtvBadge.tsx new file mode 100644 index 00000000..273af4b3 --- /dev/null +++ b/lazer/cardano/pythacoin/frontend/src/components/LtvBadge.tsx @@ -0,0 +1,18 @@ +interface Props { + ltv: number; +} + +export function LtvBadge({ ltv }: Props) { + const color = + ltv > 90 + ? "bg-red-600" + : ltv > 75 + ? "bg-yellow-600" + : "bg-green-600"; + + return ( + + {ltv.toFixed(1)}% LTV + + ); +} diff --git a/lazer/cardano/pythacoin/frontend/src/components/OpenCdpForm.tsx b/lazer/cardano/pythacoin/frontend/src/components/OpenCdpForm.tsx new file mode 100644 index 00000000..645250ed --- /dev/null +++ b/lazer/cardano/pythacoin/frontend/src/components/OpenCdpForm.tsx @@ -0,0 +1,119 @@ +import { useState } from "react"; +import { api } from "../api/client"; +import { LtvBadge } from "./LtvBadge"; + +interface Props { + address: string; + adaUsd: number | null; + balanceLovelace: number | null; + onSuccess: () => void; + signAndSubmit: (txHex: string) => Promise; +} + +export function OpenCdpForm({ address, adaUsd, balanceLovelace, onSuccess, signAndSubmit }: Props) { + const [collateral, setCollateral] = useState(""); + const [borrow, setBorrow] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const borrowNum = parseFloat(borrow) || 0; + const collateralNum = parseFloat(collateral) || 0; + const collateralUsd = adaUsd && adaUsd > 0 ? collateralNum * adaUsd : 0; + const ltv = collateralUsd > 0 ? (borrowNum / collateralUsd) * 100 : 0; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + setError(null); + try { + const req = { + collateralAda: parseFloat(collateral), + borrowPusd: parseFloat(borrow), + ownerAddress: address, + }; + console.log("[OpenCDP] Building tx with:", req); + const resp = await api.openCdp(req); + console.log("[OpenCDP] Got unsigned tx, CBOR length:", resp.txCborHex.length); + console.log("[OpenCDP] Requesting wallet signature..."); + const txHash = await signAndSubmit(resp.txCborHex); + console.log("[OpenCDP] Submitted! txHash:", txHash); + setCollateral(""); + setBorrow(""); + onSuccess(); + } catch (err) { + console.error("[OpenCDP] Error:", err); + setError(err instanceof Error ? err.message : "Failed"); + } finally { + setLoading(false); + } + }; + + return ( +
+
+

Open CDP

+ {(borrowNum > 0 || collateralNum > 0) && } +
+ + {/* Borrow amount card */} +
+ +
+ { + setBorrow(e.target.value); + const b = parseFloat(e.target.value); + if (b > 0 && adaUsd && adaUsd > 0) { + setCollateral((2 * b / adaUsd).toFixed(2)); + } + }} + className="bg-transparent text-3xl font-semibold w-full outline-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" + placeholder="0" + required + /> + PUSD +
+
+ + {/* Collateral amount card */} +
+
+ + {balanceLovelace != null && ( + + {(balanceLovelace / 1_000_000).toFixed(2)} ADA available + + )} +
+
+ setCollateral(e.target.value)} + className="bg-transparent text-3xl font-semibold w-full outline-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" + placeholder="0" + required + /> + ADA +
+
+ + {error &&

{error}

} + +
+ ); +} diff --git a/lazer/cardano/pythacoin/frontend/src/components/TopBar.tsx b/lazer/cardano/pythacoin/frontend/src/components/TopBar.tsx new file mode 100644 index 00000000..70060a65 --- /dev/null +++ b/lazer/cardano/pythacoin/frontend/src/components/TopBar.tsx @@ -0,0 +1,49 @@ +import { usePrice } from "../hooks/usePrice"; +import { WalletButton } from "./WalletButton"; + +interface Props { + connected: boolean; + address: string | null; + balanceLovelace: number | null; + balancePusd: number | null; + onConnect: () => void; + onDisconnect: () => void; +} + +export function TopBar({ connected, address, balanceLovelace, balancePusd, onConnect, onDisconnect }: Props) { + const { data: price } = usePrice(); + + return ( +
+
+

Pythacoin

+ {price && ( + + ADA/USD ${price.adaUsd.toFixed(4)} + via Pyth + + )} +
+
+ {connected && balanceLovelace != null && ( +
+ + {(balanceLovelace / 1_000_000).toFixed(2)} + ADA + + + {((balancePusd ?? 0) / 1_000_000).toFixed(2)} + PUSD + +
+ )} + +
+
+ ); +} diff --git a/lazer/cardano/pythacoin/frontend/src/components/WalletButton.tsx b/lazer/cardano/pythacoin/frontend/src/components/WalletButton.tsx new file mode 100644 index 00000000..88d51c3c --- /dev/null +++ b/lazer/cardano/pythacoin/frontend/src/components/WalletButton.tsx @@ -0,0 +1,33 @@ +interface Props { + connected: boolean; + address: string | null; + onConnect: () => void; + onDisconnect: () => void; +} + +export function WalletButton({ + connected, + address, + onConnect, + onDisconnect, +}: Props) { + if (connected && address) { + const short = address.slice(0, 8) + "..." + address.slice(-6); + return ( + + ); + } + return ( + + ); +} diff --git a/lazer/cardano/pythacoin/frontend/src/hooks/useCdps.ts b/lazer/cardano/pythacoin/frontend/src/hooks/useCdps.ts new file mode 100644 index 00000000..138d715b --- /dev/null +++ b/lazer/cardano/pythacoin/frontend/src/hooks/useCdps.ts @@ -0,0 +1,10 @@ +import { useQuery } from "@tanstack/react-query"; +import { api } from "../api/client"; + +export function useCdps() { + return useQuery({ + queryKey: ["cdps"], + queryFn: api.listCdps, + refetchInterval: 5_000, + }); +} diff --git a/lazer/cardano/pythacoin/frontend/src/hooks/usePrice.ts b/lazer/cardano/pythacoin/frontend/src/hooks/usePrice.ts new file mode 100644 index 00000000..01646279 --- /dev/null +++ b/lazer/cardano/pythacoin/frontend/src/hooks/usePrice.ts @@ -0,0 +1,10 @@ +import { useQuery } from "@tanstack/react-query"; +import { api } from "../api/client"; + +export function usePrice() { + return useQuery({ + queryKey: ["price"], + queryFn: api.getPrice, + refetchInterval: 1000, + }); +} diff --git a/lazer/cardano/pythacoin/frontend/src/hooks/useWallet.ts b/lazer/cardano/pythacoin/frontend/src/hooks/useWallet.ts new file mode 100644 index 00000000..0652288b --- /dev/null +++ b/lazer/cardano/pythacoin/frontend/src/hooks/useWallet.ts @@ -0,0 +1,159 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { Decoder } from "cbor-x"; + +const cborDecoder = new Decoder({ mapsAsObjects: false }); + +interface WalletApi { + getUsedAddresses(): Promise; + getBalance(): Promise; + signTx(txHex: string, partial: boolean): Promise; + submitTx(txHex: string): Promise; +} + +export interface WalletState { + connected: boolean; + address: string | null; + balanceLovelace: number | null; + balancePusd: number | null; + walletApi: WalletApi | null; + connect: (walletName: string) => Promise; + disconnect: () => void; + refreshBalance: () => void; +} + +declare global { + interface Window { + cardano?: Record< + string, + { + enable(): Promise; + isEnabled(): Promise; + } + >; + } +} + +function hexToBytes(hex: string): Uint8Array { + const bytes = new Uint8Array(hex.length / 2); + for (let i = 0; i < hex.length; i += 2) { + bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16); + } + return bytes; +} + +function bytesToHex(bytes: Uint8Array): string { + return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); +} + +const PUSD_NAME_HEX = "50555344"; // "PUSD" as hex + +/** Convert a CBOR-decoded key to lowercase hex, handling both Uint8Array and string. */ +function keyToHex(key: unknown): string { + if (key instanceof Uint8Array) return bytesToHex(key); + if (key instanceof ArrayBuffer) return bytesToHex(new Uint8Array(key)); + if (typeof key === "string") { + // Could be UTF-8 text or hex — encode UTF-8 bytes to hex + return Array.from(new TextEncoder().encode(key), (b) => b.toString(16).padStart(2, "0")).join(""); + } + return ""; +} + +interface WalletBalance { + lovelace: number; + pusd: number; +} + +/** Parse CIP-30 getBalance() CBOR into lovelace + PUSD amounts. */ +function decodeCborBalance(hex: string): WalletBalance { + const decoded = cborDecoder.decode(hexToBytes(hex)); + + // Pure lovelace (no multi-assets) + if (typeof decoded === "number" || typeof decoded === "bigint") { + return { lovelace: Number(decoded), pusd: 0 }; + } + + // Array [coin, multiasset_map] + if (Array.isArray(decoded) && decoded.length === 2) { + const coin = Number(decoded[0]); + const multiAsset = decoded[1]; + + if (!(multiAsset instanceof Map)) { + return { lovelace: coin, pusd: 0 }; + } + + // Find PUSD under any policy + let pusd = 0; + for (const [, assets] of multiAsset) { + if (!(assets instanceof Map)) continue; + for (const [assetKey, quantity] of assets) { + const assetHex = keyToHex(assetKey); + if (assetHex === PUSD_NAME_HEX) { + pusd += Number(quantity); + } + } + } + return { lovelace: coin, pusd }; + } + + return { lovelace: 0, pusd: 0 }; +} + +export function useWallet(): WalletState { + const [walletApi, setWalletApi] = useState(null); + const [address, setAddress] = useState(null); + const [balanceLovelace, setBalanceLovelace] = useState(null); + const [balancePusd, setBalancePusd] = useState(null); + const walletApiRef = useRef(null); + + const fetchBalance = useCallback(async () => { + const api = walletApiRef.current; + if (!api) return; + try { + const cborHex = await api.getBalance(); + const bal = decodeCborBalance(cborHex); + setBalanceLovelace(bal.lovelace); + setBalancePusd(bal.pusd); + } catch (err) { + console.error("[Wallet] Failed to fetch balance:", err); + } + }, []); + + const connect = useCallback(async (walletName: string) => { + console.log(`[Wallet] Connecting to ${walletName}...`); + const provider = window.cardano?.[walletName]; + if (!provider) throw new Error(`${walletName} wallet not found`); + const api = await provider.enable(); + const addrs = await api.getUsedAddresses(); + console.log(`[Wallet] Connected, address:`, addrs[0]); + setWalletApi(api); + walletApiRef.current = api; + setAddress(addrs[0] ?? null); + }, []); + + const disconnect = useCallback(() => { + setWalletApi(null); + walletApiRef.current = null; + setAddress(null); + setBalanceLovelace(null); + setBalancePusd(null); + }, []); + + // Poll balance every 5 seconds while connected + useEffect(() => { + if (!walletApi) return; + fetchBalance(); + const interval = setInterval(fetchBalance, 5000); + return () => clearInterval(interval); + }, [walletApi, fetchBalance]); + + return { + connected: walletApi !== null, + address, + balanceLovelace, + balancePusd, + walletApi, + connect, + disconnect, + refreshBalance: fetchBalance, + }; +} diff --git a/lazer/cardano/pythacoin/frontend/src/main.tsx b/lazer/cardano/pythacoin/frontend/src/main.tsx new file mode 100644 index 00000000..5654d4dc --- /dev/null +++ b/lazer/cardano/pythacoin/frontend/src/main.tsx @@ -0,0 +1,17 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import App from "./App"; +import "./styles/globals.css"; + +const queryClient = new QueryClient({ + defaultOptions: { queries: { refetchInterval: 10_000 } }, +}); + +createRoot(document.getElementById("root")!).render( + + + + + , +); diff --git a/lazer/cardano/pythacoin/frontend/src/styles/globals.css b/lazer/cardano/pythacoin/frontend/src/styles/globals.css new file mode 100644 index 00000000..b16c5756 --- /dev/null +++ b/lazer/cardano/pythacoin/frontend/src/styles/globals.css @@ -0,0 +1,7 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +body { + font-family: "Inter", system-ui, -apple-system, sans-serif; +} diff --git a/lazer/cardano/pythacoin/frontend/src/vite-env.d.ts b/lazer/cardano/pythacoin/frontend/src/vite-env.d.ts new file mode 100644 index 00000000..11f02fe2 --- /dev/null +++ b/lazer/cardano/pythacoin/frontend/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/lazer/cardano/pythacoin/frontend/tailwind.config.ts b/lazer/cardano/pythacoin/frontend/tailwind.config.ts new file mode 100644 index 00000000..e975afcf --- /dev/null +++ b/lazer/cardano/pythacoin/frontend/tailwind.config.ts @@ -0,0 +1,19 @@ +import type { Config } from "tailwindcss"; + +export default { + content: ["./index.html", "./src/**/*.{ts,tsx}"], + darkMode: "class", + theme: { + extend: { + colors: { + pyth: { + purple: "#7C3AED", + dark: "#0F0A1F", + card: "#1A1333", + border: "#2D2252", + }, + }, + }, + }, + plugins: [], +} satisfies Config; diff --git a/lazer/cardano/pythacoin/frontend/tsconfig.json b/lazer/cardano/pythacoin/frontend/tsconfig.json new file mode 100644 index 00000000..39a405b9 --- /dev/null +++ b/lazer/cardano/pythacoin/frontend/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"] +} diff --git a/lazer/cardano/pythacoin/frontend/vite.config.ts b/lazer/cardano/pythacoin/frontend/vite.config.ts new file mode 100644 index 00000000..94cc2a9c --- /dev/null +++ b/lazer/cardano/pythacoin/frontend/vite.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + server: { + port: 6001, + proxy: { + "/api": { + target: "http://localhost:8088", + rewrite: (path) => path.replace(/^\/api/, ""), + }, + }, + }, +}); diff --git a/lazer/cardano/pythacoin/integration/src/test/scala/pythacoin/PreprodCdpTest.scala b/lazer/cardano/pythacoin/integration/src/test/scala/pythacoin/PreprodCdpTest.scala new file mode 100644 index 00000000..5a6ad27e --- /dev/null +++ b/lazer/cardano/pythacoin/integration/src/test/scala/pythacoin/PreprodCdpTest.scala @@ -0,0 +1,153 @@ +package pythacoin + +import org.scalatest.funsuite.AnyFunSuite +import scalus.cardano.address.Network +import scalus.cardano.ledger.* +import scalus.cardano.node.BlockfrostProvider +import scalus.cardano.txbuilder.TransactionSigner +import scalus.cardano.wallet.hd.HdAccount +import scalus.crypto.ed25519.{Ed25519Signer, JvmEd25519Signer} +import scalus.utils.Hex.{hexToBytes, toHex} +import scalus.utils.await +import sttp.client4.* + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.duration.* +import scala.io.Source + +class PreprodCdpTest extends AnyFunSuite { + + given Ed25519Signer = JvmEd25519Signer + + private val apiBase = "http://localhost:8088" + + private def loadEnv(): Map[String, String] = { + val envFile = Source.fromFile(".env") + try + envFile + .getLines() + .filter(_.contains("=")) + .map { line => + val idx = line.indexOf('=') + line.substring(0, idx).trim -> line.substring(idx + 1).trim + } + .toMap + finally envFile.close() + } + + private val backend = DefaultSyncBackend() + + private def httpGet(path: String): String = { + val url = java.net.URI.create(s"$apiBase$path") + val response = basicRequest + .get(uri"$url") + .send(backend) + response.body match + case Right(body) => body + case Left(error) => throw RuntimeException(s"GET $path failed: $error") + } + + private def httpPost(path: String, json: String): String = { + val url = java.net.URI.create(s"$apiBase$path") + val response = basicRequest + .post(uri"$url") + .header("Content-Type", "application/json") + .body(json) + .send(backend) + response.body match + case Right(body) => body + case Left(error) => throw RuntimeException(s"POST $path failed: $error") + } + + private def extractField(json: String, field: String): String = { + val key = s""""$field":"""" + val idx = json.indexOf(key) + if idx < 0 then throw RuntimeException(s"Field '$field' not found in: $json") + val start = idx + key.length + val end = json.indexOf('"', start) + json.substring(start, end) + } + + test("Alice opens CDP 100 ADA / 10 PUSD, then closes it") { + val env = loadEnv() + val mnemonic = env("MNEMONIC") + val blockfrostApiKey = env("BLOCKFROST_API_KEY") + + // Derive Alice's keys from mnemonic (account 0) + val alice = HdAccount.fromMnemonic(mnemonic, "", 0) + val aliceAddr = alice.baseAddress(Network.Testnet) + val aliceAddrHex = aliceAddr.toBytes.toHex + val signer = new TransactionSigner(Set(alice.paymentKeyPair)) + + println(s"Alice address: ${aliceAddr.toBech32}") + + // Connect to Blockfrost for submission + val provider = BlockfrostProvider.preprod(blockfrostApiKey).await(30.seconds) + + // --- Step 1: Open CDP --- + println("=== Opening CDP: 100 ADA collateral, 10 PUSD borrow ===") + val openJson = + s"""{"collateralAda":100,"borrowPusd":10,"ownerAddress":"$aliceAddrHex"}""" + val openResp = httpPost("/cdp/open", openJson) + println(s"Open response: ${openResp.take(120)}...") + + val openTxHex = extractField(openResp, "txCborHex") + val openTx = Transaction.fromCbor(openTxHex.hexToBytes) + println(s"Open tx id: ${openTx.id.toHex}") + + val signedOpenTx = signer.sign(openTx) + println("Signed open tx, submitting...") + + val openResult = provider.submitAndPoll(signedOpenTx).await(120.seconds) + openResult match + case Right(txHash) => println(s"Open CDP submitted: ${txHash.toHex}") + case Left(error) => fail(s"Open CDP submission failed: $error") + + // Extract NFT name from the CDP output in the open tx + val cdpOutput = openTx.body.value.outputs.head.value + val cdpValue: Value = cdpOutput.value + val pusdHex = scalus.uplc.builtin.ByteString.fromString("PUSD").toHex + val nftName = cdpValue.assets.assets.values.flatMap(_.keys) + .map(_.bytes.toHex) + .find(_ != pusdHex) + .getOrElse(fail("No CDP NFT found in open tx outputs")) + println(s"CDP NFT name (from open tx): $nftName") + + // Wait for Blockfrost UTxO index to catch up + println("Waiting for Blockfrost to index the new UTxOs...") + Thread.sleep(10_000) + + // --- Step 2: Verify CDP exists --- + println("=== Querying CDPs ===") + val cdpsJson = httpGet("/cdps") + println(s"CDPs: $cdpsJson") + assert(cdpsJson.contains(nftName), s"Expected CDP with NFT $nftName: $cdpsJson") + + // --- Step 3: Close CDP --- + println("=== Closing CDP ===") + val closeJson = + s"""{"nftName":"$nftName","ownerAddress":"$aliceAddrHex"}""" + val closeResp = httpPost("/cdp/close", closeJson) + println(s"Close response: ${closeResp.take(120)}...") + + val closeTxHex = extractField(closeResp, "txCborHex") + val closeTx = Transaction.fromCbor(closeTxHex.hexToBytes) + println(s"Close tx id: ${closeTx.id.toHex}") + + val signedCloseTx = signer.sign(closeTx) + println("Signed close tx, submitting...") + + val closeResult = provider.submitAndPoll(signedCloseTx).await(120.seconds) + closeResult match + case Right(txHash) => println(s"Close CDP submitted: ${txHash.toHex}") + case Left(error) => fail(s"Close CDP submission failed: $error") + + // --- Step 4: Verify CDP is gone --- + println("=== Verifying CDP removed ===") + val cdpsAfter = httpGet("/cdps") + println(s"CDPs after close: $cdpsAfter") + assert(!cdpsAfter.contains(nftName), s"CDP should be removed after close: $cdpsAfter") + + println("=== Test passed! ===") + } +} diff --git a/lazer/cardano/pythacoin/integration/src/test/scala/pythacoin/YaciDevKitTest.scala b/lazer/cardano/pythacoin/integration/src/test/scala/pythacoin/YaciDevKitTest.scala new file mode 100644 index 00000000..e47fd2fc --- /dev/null +++ b/lazer/cardano/pythacoin/integration/src/test/scala/pythacoin/YaciDevKitTest.scala @@ -0,0 +1,23 @@ +package pythacoin + +import org.scalatest.Suite +import scalus.cardano.ledger.ScriptHash +import scalus.testing.yaci.{YaciConfig, YaciDevKit} + +trait YaciDevKitTest extends YaciDevKit { self: Suite => + + override protected def yaciConfig: YaciConfig = YaciConfig() + + def createAppCtx(): AppCtx = { + val context = createTestContext() + val pythPolicyId = ScriptHash.fromHex( + "aabbccddaabbccddaabbccddaabbccddaabbccddaabbccddaabbccdd" + ) + val cdpScript = CdpContract(pythPolicyId) + new AppCtx( + context.cardanoInfo, context.provider, + "", "http://localhost:8080/api/v1", + pythPolicyId, "", cdpScript + ) + } +} diff --git a/lazer/cardano/pythacoin/project/build.properties b/lazer/cardano/pythacoin/project/build.properties new file mode 100644 index 00000000..4d6c5670 --- /dev/null +++ b/lazer/cardano/pythacoin/project/build.properties @@ -0,0 +1 @@ +sbt.version=1.12.2 diff --git a/lazer/cardano/pythacoin/project/plugins.sbt b/lazer/cardano/pythacoin/project/plugins.sbt new file mode 100644 index 00000000..7d517ef3 --- /dev/null +++ b/lazer/cardano/pythacoin/project/plugins.sbt @@ -0,0 +1 @@ +addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.5.2") diff --git a/lazer/cardano/pythacoin/src/main/scala/pythacoin/CdpContract.scala b/lazer/cardano/pythacoin/src/main/scala/pythacoin/CdpContract.scala new file mode 100644 index 00000000..928864e4 --- /dev/null +++ b/lazer/cardano/pythacoin/src/main/scala/pythacoin/CdpContract.scala @@ -0,0 +1,30 @@ +package pythacoin + +import scalus.compiler.Options +import scalus.uplc.PlutusV3 +import scalus.uplc.builtin.ByteString +import scalus.uplc.builtin.Data +import scalus.uplc.builtin.Data.toData +import pythacoin.onchain.{CdpParams, CdpValidator} + +/** Compiles the CDP validator into a Plutus V3 script. + * + * The base script is compiled once (lazy) and then parameterized with the Pyth policy ID + * at deployment time via `apply()`. This produces a unique script hash per deployment + * that serves as both the minting policy ID (for PUSD + NFTs) and the script address. + */ +object CdpContract { + given Options = Options.release + + /** Unparameterized compiled script (takes param + ScriptContext). */ + lazy val base: PlutusV3[Data => Data => Unit] = + PlutusV3.compile(CdpValidator.validate) + + /** Production script: parameterized with Pyth policy ID, traces removed for smaller size. */ + def apply(pythPolicyId: ByteString): PlutusV3[Data => Unit] = + base.apply(CdpParams(pythPolicyId).toData) + + /** Debug script: keeps error trace messages for easier troubleshooting. */ + def withErrorTraces(pythPolicyId: ByteString): PlutusV3[Data => Unit] = + base.withErrorTraces.apply(CdpParams(pythPolicyId).toData) +} diff --git a/lazer/cardano/pythacoin/src/main/scala/pythacoin/CdpQueries.scala b/lazer/cardano/pythacoin/src/main/scala/pythacoin/CdpQueries.scala new file mode 100644 index 00000000..88aa0a90 --- /dev/null +++ b/lazer/cardano/pythacoin/src/main/scala/pythacoin/CdpQueries.scala @@ -0,0 +1,90 @@ +package pythacoin + +import pythacoin.onchain.CdpDatum +import scalus.cardano.ledger.* +import scalus.uplc.builtin.ByteString +import scalus.uplc.builtin.ByteString.utf8 +import scalus.utils.await + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.duration.* + +/** Read-only queries against the blockchain to discover and inspect CDPs. + * All CDPs live at the script address and are identified by a unique NFT + * under the script's policy ID. + */ +class CdpQueries(ctx: AppCtx) { + + private val policyId = ctx.policyId + private val pusdAsset = AssetName(utf8"PUSD") + + /** List all CDPs at the script address. */ + def listCdps(): Seq[CdpInfo] = { + val utxos = ctx.provider.findUtxos(ctx.scriptAddr).await(30.seconds) match + case Right(found) => found + case Left(error) => throw RuntimeException(s"Failed to query CDPs: $error") + + utxos.toSeq.flatMap { case (input, output) => + parseCdpInfo(output) + } + } + + /** Get a specific CDP by NFT name. */ + def getCdp(nftName: String): Option[CdpInfo] = + listCdps().find(_.nftName == nftName) + + /** Find the UTxO for a specific CDP by NFT name. */ + def findCdpUtxo(nftName: String): Option[Utxo] = { + val nftAsset = AssetName(ByteString.fromHex(nftName)) + val utxos = ctx.provider.findUtxos(ctx.scriptAddr).await(30.seconds) match + case Right(found) => found + case Left(error) => throw RuntimeException(s"Failed to query CDPs: $error") + + utxos.collectFirst { + case (input, output) if output.value.hasAsset(policyId, nftAsset) => + Utxo(input, output) + } + } + + /** Parse CdpInfo from a transaction output, if it's a valid CDP. */ + private def parseCdpInfo(output: TransactionOutput): Option[CdpInfo] = { + // Check that output has our policy's tokens + val assets = output.value.assets.assets.getOrElse(policyId, Map.empty) + val nftEntries = assets.filter { case (name, _) => name != pusdAsset } + + nftEntries.headOption.flatMap { case (nftName, _) => + output.inlineDatum.map { data => + val datum = data.to[CdpDatum] + val collateral = output.value.coin.value + val debt = datum.debt.toLong + CdpInfo( + nftName = nftName.bytes.toHex, + owner = datum.owner.hash.toHex, + collateralLovelace = collateral, + debtPusd = debt, + ltv = 0.0 // computed by frontend with current price + ) + } + } + } +} + +/** CDP state returned by the /cdps endpoint. + * LTV is set to 0 here — the frontend computes it using the current price. + */ +case class CdpInfo( + nftName: String, + owner: String, + collateralLovelace: Long, + debtPusd: Long, + ltv: Double +) derives upickle.default.ReadWriter + +/** Current ADA/USD price returned by the /price endpoint. + * Includes the policy ID so the frontend can identify PUSD tokens in the wallet. + */ +case class PriceInfo( + adaUsd: Double, + timestamp: String, + policyId: String +) derives upickle.default.ReadWriter diff --git a/lazer/cardano/pythacoin/src/main/scala/pythacoin/CdpTransactions.scala b/lazer/cardano/pythacoin/src/main/scala/pythacoin/CdpTransactions.scala new file mode 100644 index 00000000..e3847454 --- /dev/null +++ b/lazer/cardano/pythacoin/src/main/scala/pythacoin/CdpTransactions.scala @@ -0,0 +1,226 @@ +package pythacoin + +import pythacoin.onchain.{CdpAction, CdpDatum} +import scalus.cardano.address.{Address, Network, StakeAddress, StakePayload} +import scalus.cardano.ledger.* +import scalus.cardano.onchain.plutus.v1.PubKeyHash +import scalus.cardano.txbuilder.{TwoArgumentPlutusScriptWitness, TxBuilder, txBuilder} +import scalus.uplc.builtin.ByteString +import scalus.uplc.builtin.ByteString.utf8 +import scalus.uplc.builtin.Data +import scalus.uplc.builtin.Data.toData +import scalus.utils.await + +import java.time.Instant +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.duration.* + +/** Off-chain transaction builders for all CDP operations. + * + * Each method returns a partially-built TxBuilder that the caller completes via + * `builder.complete(provider, changeAddress)`, which handles coin selection, + * fee calculation, and collateral. The resulting unsigned transaction is then + * sent to the frontend for wallet signing. + * + * All transactions that read the oracle price include: + * - Pyth State UTxO as a reference input (provides the withdraw script hash) + * - A withdrawal with zero rewards to the Pyth withdraw script address + * - The price update bytes as the withdrawal redeemer + * This "withdrawal trick" lets the Pyth withdraw script verify the price signature + * once, and our validator can read the verified price from the redeemer. + */ +class CdpTransactions(ctx: AppCtx, pythClient: PythClient)(using CardanoInfo) { + + private val policyId = ctx.policyId + private val scriptAddr = ctx.scriptAddr + private val pusdAsset = AssetName(utf8"PUSD") + + /** Build a transaction to open a new CDP. + * The NFT name is derived from sha2_256(firstInput.txId ++ firstInput.index). + */ + def openCdp( + collateralLovelace: Long, + debtPusd: Long, + nftName: AssetName, + ownerPkh: PubKeyHash, + ownerAddr: Address, + now: Instant + ): TxBuilder = { + val (pythState, pythWithdrawAddr, updateBytes, pythWitness) = fetchPythInfo(now) + val datum = CdpDatum(ownerPkh, BigInt(debtPusd)) + + txBuilder + .references(pythState) + .withdrawRewards(pythWithdrawAddr, Coin(0), pythWitness) + .validFrom(now.minusSeconds(600)) + .validTo(now.plusSeconds(600)) + .mint( + ctx.cdpScript, + Map(nftName -> 1L, pusdAsset -> debtPusd), + CdpAction.Open + ) + .payTo( + scriptAddr, + Value.lovelace(collateralLovelace) + Value.asset(policyId, nftName, 1L), + datum.toData + ) + } + + /** Build a transaction to borrow additional PUSD from an existing CDP. */ + def borrowPusd( + cdpUtxo: Utxo, + additionalPusd: Long, + ownerAddr: Address, + now: Instant + ): TxBuilder = { + val (pythState, pythWithdrawAddr, updateBytes, pythWitness) = fetchPythInfo(now) + val oldDatum = parseCdpDatum(cdpUtxo) + val nftName = findNftName(cdpUtxo) + val newDatum = CdpDatum(oldDatum.owner, oldDatum.debt + additionalPusd) + val collateral = cdpUtxo.output.value.coin.value + + txBuilder + .references(pythState) + .withdrawRewards(pythWithdrawAddr, Coin(0), pythWitness) + .validFrom(now.minusSeconds(600)) + .validTo(now.plusSeconds(600)) + .spend(cdpUtxo, CdpAction.Borrow, ctx.cdpScript) + .mint(ctx.cdpScript, Map(pusdAsset -> additionalPusd), CdpAction.Borrow) + .payTo( + scriptAddr, + Value.lovelace(collateral) + Value.asset(policyId, nftName, 1L), + newDatum.toData + ) + .requireSignature(AddrKeyHash(oldDatum.owner.hash)) + } + + /** Build a transaction to repay PUSD debt to an existing CDP. */ + def repayPusd( + cdpUtxo: Utxo, + repayAmount: Long, + ownerAddr: Address, + now: Instant + ): TxBuilder = { + val oldDatum = parseCdpDatum(cdpUtxo) + val nftName = findNftName(cdpUtxo) + val newDatum = CdpDatum(oldDatum.owner, oldDatum.debt - repayAmount) + val collateral = cdpUtxo.output.value.coin.value + + txBuilder + .validFrom(now.minusSeconds(600)) + .validTo(now.plusSeconds(600)) + .spend(cdpUtxo, CdpAction.Repay, ctx.cdpScript) + .mint(ctx.cdpScript, Map(pusdAsset -> -repayAmount), CdpAction.Repay) + .payTo( + scriptAddr, + Value.lovelace(collateral) + Value.asset(policyId, nftName, 1L), + newDatum.toData + ) + .requireSignature(AddrKeyHash(oldDatum.owner.hash)) + } + + /** Build a transaction to close a CDP (fully repay debt, burn NFT, return collateral). */ + def closeCdp( + cdpUtxo: Utxo, + ownerAddr: Address, + now: Instant + ): TxBuilder = { + val oldDatum = parseCdpDatum(cdpUtxo) + val nftName = findNftName(cdpUtxo) + val collateral = cdpUtxo.output.value.coin.value + + txBuilder + .validFrom(now.minusSeconds(600)) + .validTo(now.plusSeconds(600)) + .spend(cdpUtxo, CdpAction.Close, ctx.cdpScript) + .mint( + ctx.cdpScript, + Map(nftName -> -1L, pusdAsset -> -oldDatum.debt.toLong), + CdpAction.Close + ) + .payTo(ownerAddr, Value.lovelace(collateral)) + .requireSignature(AddrKeyHash(oldDatum.owner.hash)) + } + + /** Build a transaction to liquidate an under-collateralized CDP. + * @param liquidatorPusdUtxos UTxOs from the liquidator that contain PUSD tokens to burn + */ + def liquidateCdp( + cdpUtxo: Utxo, + liquidatorAddr: Address, + liquidatorPusdUtxos: Utxos, + now: Instant + ): TxBuilder = { + val (pythState, pythWithdrawAddr, updateBytes, pythWitness) = fetchPythInfo(now) + val oldDatum = parseCdpDatum(cdpUtxo) + val nftName = findNftName(cdpUtxo) + val collateral = cdpUtxo.output.value.coin.value + + txBuilder + .references(pythState) + .withdrawRewards(pythWithdrawAddr, Coin(0), pythWitness) + .validFrom(now.minusSeconds(600)) + .validTo(now.plusSeconds(600)) + .spend(cdpUtxo, CdpAction.Liquidate, ctx.cdpScript) + .spend(liquidatorPusdUtxos) + .mint( + ctx.cdpScript, + Map(nftName -> -1L, pusdAsset -> -oldDatum.debt.toLong), + CdpAction.Liquidate + ) + .payTo(liquidatorAddr, Value.lovelace(collateral)) + } + + /** Fetch Pyth oracle state and build the withdrawal witness. + * + * Returns everything needed to include a Pyth price in a transaction: + * 1. The enriched Pyth State UTxO (with scriptRef manually added, since Blockfrost + * doesn't populate the scriptRef field on reference UTxOs) + * 2. The stake address of the Pyth withdraw script (for the zero-reward withdrawal) + * 3. The raw price update bytes (for off-chain display/logging) + * 4. A TwoArgumentPlutusScriptWitness using the reference script (Conway requires + * using the reference script, not attaching a copy) + */ + private def fetchPythInfo(now: Instant): (Utxo, StakeAddress, ByteString, TwoArgumentPlutusScriptWitness) = { + Log.info("Fetching Pyth oracle info...") + val pythState = pythClient.fetchPythState() + val withdrawHash = pythClient.extractWithdrawScript(pythState) + val pythWithdrawAddr = StakeAddress(ctx.cardanoInfo.network, StakePayload.Script(withdrawHash)) + Log.info(s"Pyth withdraw address: ${pythWithdrawAddr.toBech32}") + val updateBytes = pythClient.fetchPriceUpdate() + Log.info(s"Price update bytes: ${updateBytes.size} bytes") + // Blockfrost doesn't populate scriptRef on UTxOs, so we manually enrich it + val withdrawScript = pythClient.fetchScript(withdrawHash) + val enrichedOutput = TransactionOutput.Babbage( + pythState.output.address, + pythState.output.value, + pythState.output.datumOption, + Some(ScriptRef(withdrawScript)) + ) + val enrichedPythState = Utxo(pythState.input, enrichedOutput) + + // The Pyth redeemer is a list of price update byte arrays + import scalus.cardano.onchain.plutus.prelude.List as PList + val pythRedeemer: Data = Data.List(PList(Data.B(updateBytes))) + // Conway requires using reference script (not attached) — ExtraneousScriptWitnessesUTXOW error otherwise + val pythWitness = TwoArgumentPlutusScriptWitness.reference(_ => pythRedeemer) + Log.info("Pyth info fetched successfully") + + (enrichedPythState, pythWithdrawAddr, updateBytes, pythWitness) + } + + /** Parse CdpDatum from a CDP UTxO's inline datum. */ + private def parseCdpDatum(utxo: Utxo): CdpDatum = { + val data = utxo.output.requireInlineDatum + data.to[CdpDatum] + } + + /** Find the CDP NFT AssetName in a CDP UTxO (non-PUSD token under our policy). */ + private def findNftName(utxo: Utxo): AssetName = { + val assets = utxo.output.value.assets.assets.getOrElse(policyId, Map.empty) + val nftEntries = assets.filter { case (name, _) => name != pusdAsset } + nftEntries.headOption match + case Some((name, _)) => name + case None => throw RuntimeException("No CDP NFT found in UTxO") + } +} diff --git a/lazer/cardano/pythacoin/src/main/scala/pythacoin/Log.scala b/lazer/cardano/pythacoin/src/main/scala/pythacoin/Log.scala new file mode 100644 index 00000000..d5cbfef6 --- /dev/null +++ b/lazer/cardano/pythacoin/src/main/scala/pythacoin/Log.scala @@ -0,0 +1,48 @@ +package pythacoin + +import java.time.LocalTime +import java.time.format.DateTimeFormatter + +/** ANSI color logging for CLI output. */ +object Log { + private val Reset = "\u001b[0m" + private val Bold = "\u001b[1m" + private val Dim = "\u001b[2m" + private val Red = "\u001b[31m" + private val Green = "\u001b[32m" + private val Yellow = "\u001b[33m" + private val Cyan = "\u001b[36m" + private val Magenta = "\u001b[35m" + + private val timeFmt = DateTimeFormatter.ofPattern("HH:mm:ss") + private def now(): String = LocalTime.now().format(timeFmt) + + def info(msg: String): Unit = + println(s"$Dim${now()}$Reset ▸ $msg") + + def success(msg: String): Unit = + println(s"$Dim${now()}$Reset $Green✓$Reset $msg") + + def warn(msg: String): Unit = + println(s"$Dim${now()}$Reset $Yellow⚠$Reset $msg") + + def error(msg: String): Unit = + System.err.println(s"$Dim${now()}$Reset $Red✗$Reset $msg") + + def error(msg: String, e: Throwable): Unit = { + System.err.println(s"$Dim${now()}$Reset $Red✗$Reset $msg") + e.printStackTrace(System.err) + } + + def header(msg: String): Unit = + println(s"$Bold$Cyan━━━ $msg ━━━$Reset") + + def detail(label: String, value: Any): Unit = + println(s" $Dim$label:$Reset $value") + + def tx(label: String, value: String): Unit = + println(s" $Dim$label:$Reset $Magenta$value$Reset") + + def separator(): Unit = + println(s"$Dim${"─" * 72}$Reset") +} diff --git a/lazer/cardano/pythacoin/src/main/scala/pythacoin/Main.scala b/lazer/cardano/pythacoin/src/main/scala/pythacoin/Main.scala new file mode 100644 index 00000000..8b1d7d90 --- /dev/null +++ b/lazer/cardano/pythacoin/src/main/scala/pythacoin/Main.scala @@ -0,0 +1,67 @@ +package pythacoin + +import com.monovore.decline.{Command, Opts} +import scalus.cardano.address.Network + +enum Cmd: + case Blueprint, Start + +/** CLI entry point using decline for argument parsing. + * + * Two commands: + * - `blueprint`: prints the compiled script hash and size (uses a dummy Pyth policy ID) + * - `start`: launches the HTTP server on port 8088 + * + * The `start` command reads configuration from environment variables: + * - BLOCKFROST_API_KEY: Blockfrost project ID for chain queries and tx submission + * - PYTH_POLICY_ID: hex-encoded Pyth oracle deployment policy ID on the target network + * - PYTH_KEY: bearer token for the Pyth Lazer REST API + */ +object Cli: + private val command = { + val blueprintCommand = Opts.subcommand("blueprint", "Prints the contract blueprint JSON") { + Opts(Cmd.Blueprint) + } + + val startCommand = Opts.subcommand("start", "Start the server") { + Opts(Cmd.Start) + } + + Command(name = "pythacoin", header = "Pythacoin CDP Stablecoin")( + blueprintCommand orElse startCommand + ) + } + + /** Print script hash and size for a dummy parameterization (useful for CI/debugging). */ + private def blueprint(): Unit = { + val pythPolicyId = "0000000000000000000000000000000000000000000000000000000000" + val script = CdpContract(scalus.uplc.builtin.ByteString.fromHex(pythPolicyId)) + println(s"Script hash: ${script.script.scriptHash.toHex}") + println(s"Script size: ${script.program.cborEncoded.length} bytes") + } + + /** Start the HTTP server connected to preprod via Blockfrost. */ + @main + def start(): Unit = { + val blockfrostApiKey = System.getenv("BLOCKFROST_API_KEY") match + case null => sys.error("BLOCKFROST_API_KEY environment variable is not set") + case apiKey => apiKey + val pythPolicyId = System.getenv("PYTH_POLICY_ID") match + case null => sys.error("PYTH_POLICY_ID environment variable is not set") + case id => id + val pythKey = System.getenv("PYTH_KEY") match + case null => sys.error("PYTH_KEY environment variable is not set") + case key => key + val appCtx = AppCtx(Network.Testnet, blockfrostApiKey, pythPolicyId, pythKey) + println("Starting the Pythacoin server...") + Server(appCtx).start() + } + + @main def main(args: String*): Unit = { + command.parse(args) match + case Left(help) => println(help) + case Right(cmd) => + cmd match + case Cmd.Blueprint => blueprint() + case Cmd.Start => start() + } diff --git a/lazer/cardano/pythacoin/src/main/scala/pythacoin/PythClient.scala b/lazer/cardano/pythacoin/src/main/scala/pythacoin/PythClient.scala new file mode 100644 index 00000000..c2ab75ab --- /dev/null +++ b/lazer/cardano/pythacoin/src/main/scala/pythacoin/PythClient.scala @@ -0,0 +1,210 @@ +package pythacoin + +import scalus.cardano.address.{Address, Network, StakeAddress, StakePayload} +import scalus.cardano.ledger.* +import scalus.cardano.node.BlockchainProvider +import scalus.uplc.builtin.ByteString +import scalus.utils.Hex.toHex +import scalus.utils.await +import sttp.client4.* + +import java.util.Base64 +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future +import scala.concurrent.duration.* + +/** Client for interacting with the Pyth oracle on Cardano. + * + * Handles two concerns: + * 1. Fetching signed price updates from the Pyth Lazer REST API (off-chain data source) + * 2. Looking up the Pyth State UTxO and withdraw script on-chain via Blockfrost + * + * The Pyth integration pattern on Cardano uses a "withdrawal trick": + * - A Pyth State UTxO holds the withdraw script hash in its datum + * - Transactions include the price update as a withdrawal redeemer + * - The Pyth withdraw script validates the signature on the price data + * - Our CDP validator reads the price from the withdrawal redeemer (no sig verification needed) + */ +class PythClient( + pythPolicyId: ScriptHash, + pythKey: String, + blockfrostApiKey: String, + blockfrostBaseUrl: String, + provider: BlockchainProvider +)(using backend: Backend[Future]) { + + private val lazerUrl = "https://pyth-lazer.dourolabs.app/v1/latest_price" + + /** Fetch signed price update bytes from Pyth Lazer REST API. */ + def fetchPriceUpdate(): ByteString = { +// Log.info("Fetching price update from Pyth Lazer...") + val requestBody = + s"""{"priceFeedIds":[16],"properties":["price"],"formats":["solana"],"channel":"fixed_rate@200ms"}""" + val response = basicRequest + .post(uri"$lazerUrl") + .header("Authorization", s"Bearer $pythKey") + .header("Content-Type", "application/json") + .body(requestBody) + .send(backend) + .await(30.seconds) + + response.body match + case Right(body) => + val encoding = extractSolanaEncoding(body) + val data = extractSolanaData(body) +// Log.info(s"Pyth Lazer response encoding=$encoding, data length=${data.length}") + encoding match + case "base64" => ByteString.fromArray(Base64.getDecoder.decode(data)) + case _ => ByteString.fromHex(data) + case Left(error) => + Log.error(s"Pyth Lazer API error: $error") + throw RuntimeException(s"Pyth Lazer API error: $error") + } + + /** Find Pyth State UTxO by looking for the NFT with "Pyth State" token name. */ + def fetchPythState(): Utxo = { + val pythStateName = AssetName(ByteString.fromString("Pyth State")) + val asset = pythPolicyId.toHex + pythStateName.bytes.toHex + Log.info(s"Fetching Pyth State UTxO, asset=$asset") + + // Step 1: find addresses holding this asset via Blockfrost + val addrResponse = basicRequest + .get(uri"$blockfrostBaseUrl/assets/$asset/addresses") + .header("project_id", blockfrostApiKey) + .send(backend) + .await(30.seconds) + + val addrJson = addrResponse.body match + case Right(body) => body + case Left(error) => + Log.error(s"Failed to find Pyth State asset: $error") + throw RuntimeException(s"Failed to find Pyth State asset: $error") + + // Extract first address from [{"address":"addr...","quantity":"1"}] + val addrKey = "\"address\":\"" + val idx = addrJson.indexOf(addrKey) + if idx < 0 then throw RuntimeException(s"No address found for Pyth State asset: $addrJson") + val start = idx + addrKey.length + val end = addrJson.indexOf('"', start) + val addrBech32 = addrJson.substring(start, end) + Log.info(s"Pyth State address: $addrBech32") + val addr = Address.fromBech32(addrBech32) + + // Step 2: query UTxOs at that address filtered by asset + val utxos = provider.findUtxos(addr).await(30.seconds) match + case Right(found) => + Log.info(s"Found ${found.size} UTxOs at Pyth State address") + found + case Left(error) => + Log.error(s"Failed to query Pyth State UTxOs: $error") + throw RuntimeException(s"Failed to query Pyth State UTxOs: $error") + + utxos.collectFirst { + case (input, output) if output.value.hasAsset(pythPolicyId, pythStateName) => + Log.info(s"Found Pyth State UTxO: ${input.transactionId.toHex}#${input.index}") + Utxo(input, output) + }.getOrElse(throw RuntimeException("Pyth State UTxO not found")) + } + + /** Extract withdraw script hash from Pyth State inline datum (field 4). */ + def extractWithdrawScript(pythState: Utxo): ScriptHash = { + import scalus.uplc.builtin.Data + val datum: Data = pythState.output.requireInlineDatum + val fields = datum.toConstr.snd + // fields: governance, trusted_signers, deprecated_withdraw_scripts, withdraw_script + val withdrawScriptBs = fields.tail.tail.tail.head.toByteString + val hash = ScriptHash.fromHex(withdrawScriptBs.toHex) + Log.info(s"Pyth withdraw script hash: ${hash.toHex}") + hash + } + + /** Fetch the Pyth withdraw PlutusScript from Blockfrost by script hash. + * + * Blockfrost returns CBOR-wrapped script bytes. Sometimes the hash of the raw bytes + * doesn't match because Blockfrost double-wraps the CBOR. We try raw first, then + * unwrap one CBOR layer if the hash doesn't match. + */ + def fetchScript(scriptHash: ScriptHash): PlutusScript = { + val hashHex = scriptHash.toHex + Log.info(s"Fetching script CBOR from Blockfrost: $hashHex") + val response = basicRequest + .get(uri"$blockfrostBaseUrl/scripts/$hashHex/cbor") + .header("project_id", blockfrostApiKey) + .send(backend) + .await(30.seconds) + + val json = response.body match + case Right(body) => body + case Left(error) => + Log.error(s"Failed to fetch script $hashHex: $error") + throw RuntimeException(s"Failed to fetch script $hashHex: $error") + + val cborKey = "\"cbor\":\"" + val idx = json.indexOf(cborKey) + if idx < 0 then throw RuntimeException(s"No cbor field in response: $json") + val start = idx + cborKey.length + val end = json.indexOf('"', start) + val cborHex = json.substring(start, end) + val rawBytes = ByteString.fromHex(cborHex) + Log.info(s"Script CBOR: hexLen=${cborHex.length}, bytes=${rawBytes.size}, expected serialised_size=2745") + Log.info(s"Script CBOR first 10 hex: ${cborHex.take(20)}, last 10 hex: ${cborHex.takeRight(20)}") + + val script = Script.PlutusV3(rawBytes) + if script.scriptHash.toHex == hashHex then + Log.info(s"Script hash matches: $hashHex (${rawBytes.size} bytes)") + script + else + // Blockfrost sometimes double-CBOR-wraps the script — unwrap one layer + Log.info(s"Raw hash ${script.scriptHash.toHex} != $hashHex, trying CBOR unwrap...") + val inner = scalus.serialization.cbor.Cbor.decode[Array[Byte]](rawBytes.bytes) + val script2 = Script.PlutusV3(ByteString.unsafeFromArray(inner)) + Log.info(s"After unwrap: hash=${script2.scriptHash.toHex}, expected=$hashHex, size=${inner.length} bytes") + script2 + } + + /** Build the StakeAddress for the Pyth withdraw script. */ + def pythWithdrawAddress(network: Network): StakeAddress = { + val pythState = fetchPythState() + val withdrawHash = extractWithdrawScript(pythState) + StakeAddress(network, StakePayload.Script(withdrawHash)) + } + + /** Parse ADA/USD price from Pyth update bytes for display (off-chain). + * Mirrors the on-chain parsePythPrice logic but uses JVM ByteBuffer for convenience. + * Returns price as a BigDecimal (e.g. 0.7523 for $0.7523/ADA). + */ + def parsePrice(updateBytes: ByteString): BigDecimal = { + import java.nio.{ByteBuffer, ByteOrder} + val buf = ByteBuffer.wrap(updateBytes.bytes).order(ByteOrder.LITTLE_ENDIAN) + // Solana envelope: [4 magic][64 sig][32 key][2 payload_size] = 102 bytes + // Payload header: [4 magic][8 timestamp][1 channel][1 feeds_len] = 14 bytes + // Feed starts at 102 + 14 = 116 + val feedOffset = 116 + // Feed: [4 feed_id][1 props_len][1 prop_id][8 price I64 LE] + val priceOffset = feedOffset + 6 + buf.position(priceOffset) + val priceRaw = buf.getLong() + BigDecimal(priceRaw) / BigDecimal(100_000_000L) // exponent = -8 + } + + /** Extract the solana.encoding field from JSON response. */ + private def extractSolanaEncoding(json: String): String = { + val key = "\"encoding\":\"" + val idx = json.lastIndexOf(key) + if idx < 0 then return "hex" // default + val start = idx + key.length + val end = json.indexOf('"', start) + if end < 0 then "hex" else json.substring(start, end) + } + + /** Extract the solana.data field from JSON response. */ + private def extractSolanaData(json: String): String = { + val dataKey = "\"data\":\"" + val idx = json.lastIndexOf(dataKey) + if idx < 0 then throw RuntimeException(s"Cannot find solana.data in response: $json") + val start = idx + dataKey.length + val end = json.indexOf('"', start) + if end < 0 then throw RuntimeException(s"Malformed solana.data in response: $json") + json.substring(start, end) + } +} diff --git a/lazer/cardano/pythacoin/src/main/scala/pythacoin/Server.scala b/lazer/cardano/pythacoin/src/main/scala/pythacoin/Server.scala new file mode 100644 index 00000000..015062a3 --- /dev/null +++ b/lazer/cardano/pythacoin/src/main/scala/pythacoin/Server.scala @@ -0,0 +1,397 @@ +package pythacoin + +import scalus.cardano.address.{Address, Network as ScalusNetwork} +import scalus.cardano.ledger.* +import scalus.cardano.node.{BlockchainProvider, BlockfrostProvider} +import scalus.uplc.PlutusV3 +import scalus.uplc.builtin.{ByteString, Data} +import scalus.serialization.cbor.Cbor +import scalus.utils.Hex.{hexToBytes, toHex} +import scalus.utils.{await, showDetailedHighlighted} +import sttp.client4.DefaultFutureBackend +import scalus.cardano.address.{ShelleyAddress, ShelleyPaymentPart} +import scalus.cardano.onchain.plutus.v1.PubKeyHash +import sttp.shared.Identity +import sttp.tapir.* +import sttp.tapir.generic.auto.* +import sttp.tapir.json.upickle.* +import sttp.tapir.server.ServerEndpoint +import sttp.tapir.server.netty.sync.NettySyncServer +import sttp.tapir.swagger.bundle.SwaggerInterpreter + +import java.time.Instant +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future +import scala.concurrent.duration.* + +/** Global sttp HTTP backend used by PythClient and BlockfrostProvider. */ +given sttp.client4.Backend[Future] = DefaultFutureBackend() + +/** Application context holding all shared state and services. + * Lazily initializes derived objects (policy ID, script address, clients) + * so that construction is fast and initialization errors surface at first use. + */ +case class AppCtx( + cardanoInfo: CardanoInfo, + provider: BlockchainProvider, + blockfrostApiKey: String, + blockfrostBaseUrl: String, + pythPolicyId: ScriptHash, + pythKey: String, + cdpScript: PlutusV3[Data => Unit] +) { + /** The script hash doubles as the minting policy ID for PUSD and CDP NFTs. */ + lazy val policyId: ScriptHash = cdpScript.script.scriptHash + /** The script address where all CDP UTxOs live. */ + lazy val scriptAddr: Address = cdpScript.address(cardanoInfo.network) + lazy val pythClient: PythClient = PythClient(pythPolicyId, pythKey, blockfrostApiKey, blockfrostBaseUrl, provider) + lazy val cdpQueries: CdpQueries = CdpQueries(this) + lazy val cdpTransactions: CdpTransactions = { + given CardanoInfo = cardanoInfo + CdpTransactions(this, pythClient) + } +} + +object AppCtx { + + /** Create AppCtx for mainnet or preprod using Blockfrost as the chain provider. */ + def apply( + network: ScalusNetwork, + blockfrostApiKey: String, + pythPolicyIdHex: String, + pythKey: String + ): AppCtx = { + val (provider, baseUrl) = + if network == ScalusNetwork.Mainnet then + (BlockfrostProvider.mainnet(blockfrostApiKey).await(30.seconds), "https://cardano-mainnet.blockfrost.io/api/v0") + else if network == ScalusNetwork.Testnet then + (BlockfrostProvider.preprod(blockfrostApiKey).await(30.seconds), "https://cardano-preprod.blockfrost.io/api/v0") + else sys.error(s"Unsupported network: $network") + + val pythPolicy = ScriptHash.fromHex(pythPolicyIdHex) + val cdpScript = CdpContract(pythPolicy) + + new AppCtx(provider.cardanoInfo, provider, blockfrostApiKey, baseUrl, pythPolicy, pythKey, cdpScript) + } + + /** Create AppCtx for local development using Yaci DevKit (no real Pyth oracle). */ + def yaciDevKit(pythPolicyIdHex: String): AppCtx = { + val provider = BlockfrostProvider.localYaci().await(30.seconds) + val pythPolicy = ScriptHash.fromHex(pythPolicyIdHex) + val cdpScript = CdpContract(pythPolicy) + + new AppCtx(provider.cardanoInfo, provider, "", "http://localhost:8080/api/v1", pythPolicy, "", cdpScript) + } +} + +// --- Request/Response types --- +// All amounts in the API use human-readable units (ADA, PUSD) as doubles. +// The server converts to lovelace (1 ADA = 1_000_000 lovelace) internally. +// Addresses can be CIP-30 hex (from wallet) or bech32 (addr1...). + +case class OpenCdpRequest( + collateralAda: Double, + borrowPusd: Double, + ownerAddress: String +) derives upickle.default.ReadWriter + +case class BorrowRequest( + nftName: String, // hex-encoded NFT asset name + amount: Double, // additional PUSD to borrow + ownerAddress: String +) derives upickle.default.ReadWriter + +case class RepayRequest( + nftName: String, + amount: Double, // PUSD to repay + ownerAddress: String +) derives upickle.default.ReadWriter + +case class CloseRequest( + nftName: String, + ownerAddress: String +) derives upickle.default.ReadWriter + +case class LiquidateRequest( + nftName: String, + liquidatorAddress: String +) derives upickle.default.ReadWriter + +/** Response containing an unsigned transaction CBOR for the frontend to sign. */ +case class TxResponse( + txCborHex: String +) derives upickle.default.ReadWriter + +/** Request to merge wallet witness with unsigned tx and submit to the chain. */ +case class SubmitTxRequest( + txCborHex: String, // unsigned tx CBOR from the build step + witnessCborHex: String // CIP-30 signTx result (TransactionWitnessSet CBOR) +) derives upickle.default.ReadWriter + +case class SubmitTxResponse( + txHash: String +) derives upickle.default.ReadWriter + +/** HTTP API server built with Tapir (served by Netty). + * + * Endpoints follow a build-then-sign pattern: + * 1. Frontend calls POST /cdp/open (or borrow/repay/close/liquidate) with parameters + * 2. Server builds an unsigned transaction and returns CBOR hex + * 3. Frontend signs via CIP-30 wallet (signTx with partial=true) + * 4. Frontend calls POST /tx/submit with unsigned tx + wallet witness + * 5. Server merges witnesses and submits to Blockfrost + * + * Auto-generated Swagger UI is available at /docs. + */ +class Server(ctx: AppCtx): + private given CardanoInfo = ctx.cardanoInfo + + /** Parse address from hex (CIP-30 getUsedAddresses returns hex) or bech32 string. */ + private def parseAddress(addr: String): Address = + val parsed = if addr.startsWith("addr") then Address.fromBech32(addr) + else Address.fromBytes(addr.hexToBytes) + Log.info(s"Parsed address: ${addr.take(20)}... -> $parsed") + parsed + + // --- GET /price --- + private val getPrice = endpoint.get + .in("price") + .out(jsonBody[PriceInfo]) + .errorOut(stringBody) + .handle { _ => + try + val updateBytes = ctx.pythClient.fetchPriceUpdate() + val price = ctx.pythClient.parsePrice(updateBytes) + Log.info(s"Fetched price from Pyth Lazer: $price USD/ADA") + Right(PriceInfo( + adaUsd = price.toDouble, + timestamp = Instant.now().toString, + policyId = ctx.policyId.toHex + )) + catch case e: Exception => Left(e.getMessage) + } + + // --- GET /cdps --- + private val listCdpsEndpoint = endpoint.get + .in("cdps") + .out(jsonBody[Seq[CdpInfo]]) + .errorOut(stringBody) + .handle { _ => + try Right(ctx.cdpQueries.listCdps()) + catch case e: Exception => Left(e.getMessage) + } + + // --- POST /cdp/open --- + private val openCdpEndpoint = endpoint.post + .in("cdp" / "open") + .in(jsonBody[OpenCdpRequest]) + .out(jsonBody[TxResponse]) + .errorOut(stringBody) + .handle { req => + try + Log.info(s"POST /cdp/open: collateral=${req.collateralAda} ADA, borrow=${req.borrowPusd} PUSD") + val ownerAddr = parseAddress(req.ownerAddress) + val collateralLovelace = (req.collateralAda * 1_000_000).toLong + val debtPusd = (req.borrowPusd * 1_000_000).toLong + val ownerPkh = ownerAddr match + case s: ShelleyAddress => s.payment match + case ShelleyPaymentPart.Key(hash) => PubKeyHash(hash: ByteString) + case _ => throw RuntimeException("Owner address must have a key credential") + case _ => throw RuntimeException("Owner address must be a Shelley address") + Log.info(s"Owner PKH: ${ownerPkh.hash.toHex}") + val nftName = AssetName(ByteString.fromString("CDP-" + System.currentTimeMillis())) + Log.info(s"NFT name: ${nftName.bytes.toHex}") + val now = Instant.now() + Log.info("Building openCdp transaction...") + val builder = ctx.cdpTransactions.openCdp( + collateralLovelace, debtPusd, nftName, ownerPkh, ownerAddr, now + ) + Log.info("Completing transaction...") + val completed = builder.complete(ctx.provider, ownerAddr).await(30.seconds) + val tx = completed.transaction + Log.info(s"Transaction built successfully:\n${tx.showDetailedHighlighted}") + Right(TxResponse(tx.toCbor.toHex)) + catch case e: Exception => + Log.error(s"POST /cdp/open failed: ${e.getMessage}", e) + Left(e.getMessage) + } + + // --- POST /cdp/borrow --- + private val borrowEndpoint = endpoint.post + .in("cdp" / "borrow") + .in(jsonBody[BorrowRequest]) + .out(jsonBody[TxResponse]) + .errorOut(stringBody) + .handle { req => + try + Log.info(s"POST /cdp/borrow: nft=${req.nftName}, amount=${req.amount}") + val ownerAddr = parseAddress(req.ownerAddress) + val cdpUtxo = ctx.cdpQueries.findCdpUtxo(req.nftName).getOrElse( + throw RuntimeException(s"CDP not found: ${req.nftName}") + ) + val amount = (req.amount * 1_000_000).toLong + val now = Instant.now() + val builder = ctx.cdpTransactions.borrowPusd(cdpUtxo, amount, ownerAddr, now) + val completed = builder.complete(ctx.provider, ownerAddr).await(30.seconds) + val tx = completed.transaction + Log.info(s"Borrow tx built:\n${tx.showDetailedHighlighted}") + Right(TxResponse(tx.toCbor.toHex)) + catch case e: Exception => + Log.error(s"POST /cdp/borrow failed: ${e.getMessage}", e) + Left(e.getMessage) + } + + // --- POST /cdp/repay --- + private val repayEndpoint = endpoint.post + .in("cdp" / "repay") + .in(jsonBody[RepayRequest]) + .out(jsonBody[TxResponse]) + .errorOut(stringBody) + .handle { req => + try + Log.info(s"POST /cdp/repay: nft=${req.nftName}, amount=${req.amount}") + val ownerAddr = parseAddress(req.ownerAddress) + val cdpUtxo = ctx.cdpQueries.findCdpUtxo(req.nftName).getOrElse( + throw RuntimeException(s"CDP not found: ${req.nftName}") + ) + val amount = (req.amount * 1_000_000).toLong + val now = Instant.now() + val builder = ctx.cdpTransactions.repayPusd(cdpUtxo, amount, ownerAddr, now) + val completed = builder.complete(ctx.provider, ownerAddr).await(30.seconds) + val tx = completed.transaction + Log.info(s"Repay tx built:\n${tx.showDetailedHighlighted}") + Right(TxResponse(tx.toCbor.toHex)) + catch case e: Exception => + Log.error(s"POST /cdp/repay failed: ${e.getMessage}", e) + Left(e.getMessage) + } + + // --- POST /cdp/close --- + private val closeEndpoint = endpoint.post + .in("cdp" / "close") + .in(jsonBody[CloseRequest]) + .out(jsonBody[TxResponse]) + .errorOut(stringBody) + .handle { req => + try + Log.info(s"POST /cdp/close: nft=${req.nftName}") + val ownerAddr = parseAddress(req.ownerAddress) + val cdpUtxo = ctx.cdpQueries.findCdpUtxo(req.nftName).getOrElse( + throw RuntimeException(s"CDP not found: ${req.nftName}") + ) + val now = Instant.now() + val builder = ctx.cdpTransactions.closeCdp(cdpUtxo, ownerAddr, now) + val completed = builder.complete(ctx.provider, ownerAddr).await(30.seconds) + val tx = completed.transaction + Log.info(s"Close tx built:\n${tx.showDetailedHighlighted}") + Right(TxResponse(tx.toCbor.toHex)) + catch case e: Exception => + Log.error(s"POST /cdp/close failed: ${e.getMessage}", e) + Left(e.getMessage) + } + + // --- POST /cdp/liquidate --- + private val liquidateEndpoint = endpoint.post + .in("cdp" / "liquidate") + .in(jsonBody[LiquidateRequest]) + .out(jsonBody[TxResponse]) + .errorOut(stringBody) + .handle { req => + try + Log.info(s"POST /cdp/liquidate: nft=${req.nftName}") + val liquidatorAddr = parseAddress(req.liquidatorAddress) + val cdpUtxo = ctx.cdpQueries.findCdpUtxo(req.nftName).getOrElse( + throw RuntimeException(s"CDP not found: ${req.nftName}") + ) + // Pre-check: verify the liquidator has enough PUSD to cover the CDP's debt. + // We also collect the specific UTxOs containing PUSD so the tx builder can + // explicitly spend them (needed because PUSD lives at the liquidator's address, + // not at the script address, so automatic coin selection won't find them). + val datum = cdpUtxo.output.requireInlineDatum.to[pythacoin.onchain.CdpDatum] + val debtPusd = datum.debt.toLong + val pusdAsset = AssetName(ByteString.fromString("PUSD")) + val liquidatorUtxos = ctx.provider.findUtxos(liquidatorAddr).await(30.seconds) match + case Right(found) => found + case Left(error) => throw RuntimeException(s"Failed to query liquidator UTxOs: $error") + val pusdUtxos = liquidatorUtxos.filter { case (_, output) => + output.value.asset(ctx.policyId, pusdAsset) > 0 + } + val liquidatorPusd = pusdUtxos.map(_._2.value.asset(ctx.policyId, pusdAsset)).sum + if liquidatorPusd < debtPusd then + throw RuntimeException( + s"Insufficient PUSD: liquidator has ${liquidatorPusd / 1_000_000.0} PUSD but needs ${debtPusd / 1_000_000.0} PUSD to cover debt" + ) + val now = Instant.now() + val builder = ctx.cdpTransactions.liquidateCdp(cdpUtxo, liquidatorAddr, pusdUtxos, now) + val completed = builder.complete(ctx.provider, liquidatorAddr).await(30.seconds) + val tx = completed.transaction + Log.info(s"Liquidate tx built:\n${tx.showDetailedHighlighted}") + Right(TxResponse(tx.toCbor.toHex)) + catch case e: Exception => + Log.error(s"POST /cdp/liquidate failed: ${e.getMessage}", e) + Left(e.getMessage) + } + + // --- POST /tx/submit --- + // Merges the wallet's vkey witnesses (from CIP-30 signTx) into the unsigned transaction + // and submits the fully signed transaction to the chain via Blockfrost. + // This two-step pattern is needed because CIP-30 signTx(partial=true) returns only + // the user's witness set, not a complete signed transaction. + private val submitTxEndpoint = endpoint.post + .in("tx" / "submit") + .in(jsonBody[SubmitTxRequest]) + .out(jsonBody[SubmitTxResponse]) + .errorOut(stringBody) + .handle { req => + try + Log.info(s"POST /tx/submit: txCborHex=${if req.txCborHex == null then "NULL" else s"${req.txCborHex.length} chars"}, witnessCborHex=${if req.witnessCborHex == null then "NULL" else s"${req.witnessCborHex.length} chars"}") + require(req.txCborHex != null && req.txCborHex.nonEmpty, "txCborHex is required") + require(req.witnessCborHex != null && req.witnessCborHex.nonEmpty, "witnessCborHex is required") + given ProtocolVersion = ProtocolVersion.conwayPV + val unsignedTx = Transaction.fromCbor(req.txCborHex.hexToBytes) + Log.info(s"Parsed unsigned tx: ${unsignedTx.id.toHex}") + // Decode the wallet's TransactionWitnessSet from CBOR + val witnessBytes = req.witnessCborHex.hexToBytes + given OriginalCborByteArray = OriginalCborByteArray(witnessBytes) + val walletWitnesses = Cbor.decode[TransactionWitnessSet](witnessBytes) + Log.info(s"Wallet provided ${walletWitnesses.vkeyWitnesses.toSet.size} vkey witnesses") + // Merge wallet vkeys with any existing witnesses (e.g. from script evaluation) + val existing = unsignedTx.witnessSet + val mergedVkeys = TaggedSortedSet.from( + existing.vkeyWitnesses.toSet ++ walletWitnesses.vkeyWitnesses.toSet + ) + val signedTx = unsignedTx.withWitness(existing.copy(vkeyWitnesses = mergedVkeys)) + Log.info(s"Submitting tx ${signedTx.id.toHex} with ${mergedVkeys.toSet.size} vkey witnesses...") + val result = ctx.provider.submit(signedTx).await(30.seconds) + result match + case Right(txHash) => + Log.info(s"Tx submitted: ${txHash.toHex}") + Right(SubmitTxResponse(txHash.toHex)) + case Left(error) => + Log.error(s"Tx submission failed: $error") + Left(s"Submission failed: $error") + catch case e: Exception => + val msg = Option(e.getMessage).getOrElse(e.getClass.getName) + Log.error(s"POST /tx/submit failed: $msg", e) + Left(msg) + } + + private val apiEndpoints: List[ServerEndpoint[Any, Identity]] = List( + getPrice, + listCdpsEndpoint, + openCdpEndpoint, + borrowEndpoint, + repayEndpoint, + closeEndpoint, + liquidateEndpoint, + submitTxEndpoint + ) + + private val swaggerEndpoints = SwaggerInterpreter() + .fromServerEndpoints[Identity](apiEndpoints, "Pythacoin", "0.1") + + def start(): Unit = + NettySyncServer() + .port(8088) + .addEndpoints(apiEndpoints ++ swaggerEndpoints) + .startAndWait() diff --git a/lazer/cardano/pythacoin/src/main/scala/pythacoin/onchain/CdpValidator.scala b/lazer/cardano/pythacoin/src/main/scala/pythacoin/onchain/CdpValidator.scala new file mode 100644 index 00000000..a5e14a03 --- /dev/null +++ b/lazer/cardano/pythacoin/src/main/scala/pythacoin/onchain/CdpValidator.scala @@ -0,0 +1,446 @@ +package pythacoin.onchain + +import scalus.* +import scalus.cardano.onchain.plutus.prelude.* +import scalus.cardano.onchain.plutus.v1.Credential.{PubKeyCredential, ScriptCredential} +import scalus.cardano.onchain.plutus.v1.TokenName +import scalus.cardano.onchain.plutus.v2.OutputDatum +import scalus.cardano.onchain.plutus.v3.{DataParameterizedValidator, Validator as _, *} +import scalus.uplc.builtin.ByteString.utf8 +import scalus.uplc.builtin.Data.toData +import scalus.uplc.builtin.{Builtins, ByteString, Data, FromData, ToData} +import pythacoin.onchain.StrictLookups.* + +/** Inline datum stored at each CDP UTxO. + * @param owner the public key hash of the CDP owner (who can borrow/repay/close) + * @param debt outstanding PUSD debt in lovelace-scale (1 PUSD = 1_000_000) + */ +case class CdpDatum( + owner: PubKeyHash, + debt: BigInt +) derives FromData, + ToData + +/** Script parameter baked into the compiled validator at deployment time. + * @param pythPolicyId the Pyth oracle deployment policy ID, used to locate Pyth State UTxO + */ +case class CdpParams( + pythPolicyId: ByteString +) derives FromData, + ToData + +/** Redeemer that selects which action the validator should enforce. + * The same enum is used for both the spend and mint handlers. + */ +enum CdpAction derives FromData, ToData { + case Open + case Borrow + case Repay + case Close + case Liquidate +} + +/** Protocol constants compiled into the on-chain script. */ +@Compile +object CdpConsts { + val MAX_LTV: BigInt = 95 // 95% — maximum loan-to-value ratio for minting/borrowing + val LIQ_THRESHOLD: BigInt = 90 // 90% — LTV above which a CDP can be liquidated by anyone + val LTV_SCALE: BigInt = 100 // denominator for percentage arithmetic (100 = 100%) + val PUSD_TOKEN_NAME: TokenName = utf8"PUSD" + val PYTH_STATE_NAME: TokenName = utf8"Pyth State" + val ADA_USD_FEED_ID: BigInt = 16 // Pyth Lazer feed ID for ADA/USD + val ORACLE_SCALE: BigInt = 100_000_000 // price has 8 decimal places (exponent = -8) +} + +import pythacoin.onchain.CdpConsts.* + +@Compile +object CdpValidator extends DataParameterizedValidator { + + // --- Binary parsing helpers for Pyth Lazer update bytes --- + // These convert little-endian byte slices into integers on-chain. + // Plutus has no native LE integer parsing, so we use Builtins.byteStringToInteger + // with endianness=false (little-endian) and manual two's complement for signed types. + + /** Parse unsigned integer from little-endian bytes at a given offset and size. */ + def leToUInt(bs: ByteString, offset: BigInt, size: BigInt): BigInt = + Builtins.byteStringToInteger(false, bs.slice(offset, size)) + + val I16_SIGN: BigInt = BigInt(32768) // 2^15 — sign threshold for I16 + val I16_MOD: BigInt = BigInt(65536) // 2^16 — modulus for I16 two's complement + val I64_SIGN: BigInt = BigInt("9223372036854775808") // 2^63 — sign threshold for I64 + val I64_MOD: BigInt = BigInt("18446744073709551616") // 2^64 — modulus for I64 two's complement + + /** Parse signed 64-bit integer from LE bytes (two's complement). */ + def leToI64(bs: ByteString, offset: BigInt): BigInt = { + val unsigned = leToUInt(bs, offset, BigInt(8)) + if unsigned < I64_SIGN then unsigned + else unsigned - I64_MOD + } + + /** Parse signed 16-bit integer from LE bytes (two's complement). */ + def leToI16(bs: ByteString, offset: BigInt): BigInt = { + val unsigned = leToUInt(bs, offset, BigInt(2)) + if unsigned < I16_SIGN then unsigned + else unsigned - I16_MOD + } + + def leToU32(bs: ByteString, offset: BigInt): BigInt = + leToUInt(bs, offset, BigInt(4)) + + def leToU16(bs: ByteString, offset: BigInt): BigInt = + leToUInt(bs, offset, BigInt(2)) + + def readU8(bs: ByteString, offset: BigInt): BigInt = + bs.at(offset) + + /** Extract ADA/USD price from Pyth update bytes. + * + * Message format (Solana): [4 magic][64 sig][32 key][2 payload_size][payload...] + * + * Payload format: [4 magic][8 timestamp_us][1 channel_id][1 feeds_len][feeds...] + * + * Feed format: [4 feed_id][1 props_len][props...] + * + * Property: [1 prop_id][data...] prop 0 = Price (I64), prop 4 = Exponent (I16) + */ + def parsePythPrice(updateBytes: ByteString): BigInt = { + // Skip envelope: 4 magic + 64 sig + 32 key = 100 bytes + val payloadSize = leToU16(updateBytes, BigInt(100)) + // payload starts at offset 102 + // Skip payload header: 4 magic + 8 timestamp + 1 channel + 1 feeds_len = 14 bytes + // First feed starts at 102 + 14 = 116 + val feedOffset = BigInt(116) + val feedId = leToU32(updateBytes, feedOffset) + require(feedId === ADA_USD_FEED_ID, "Expected ADA/USD feed") + val propsLen = readU8(updateBytes, feedOffset + 4) + // Parse first property which should be Price (prop_id = 0) + val propOffset = feedOffset + 5 + val propId = readU8(updateBytes, propOffset) + require(propId === BigInt(0), "First property must be Price") + val price = leToI64(updateBytes, propOffset + 1) + require(price > BigInt(0), "Price must be positive") + price + } + + /** Get Pyth price from transaction. + * + * 1. Find Pyth State UTxO in reference inputs 2. Extract withdraw_script hash from datum 3. + * Find withdrawal redeemer for that script 4. Parse price from first update + */ + def getPythPrice(tx: TxInfo, pythPolicyId: ByteString): BigInt = { + // 1. Find Pyth State UTxO in reference inputs + val pythState = tx.referenceInputs + .find(ref => + ref.resolved.value.quantityOf(pythPolicyId, PYTH_STATE_NAME) === BigInt(1) + ) + .getOrFail("Pyth State reference input not found") + + // 2. Extract withdraw_script hash from Pyth state datum (4th field) + val stateDatum = pythState.resolved.datum match + case OutputDatum.OutputDatum(d) => d + case _ => fail("Pyth State must have inline datum") + val stateFields = stateDatum.toConstr.snd + // fields: governance, trusted_signers, deprecated_withdraw_scripts, withdraw_script + val withdrawScript = stateFields.tail.tail.tail.head.toByteString + + // 3. Find withdrawal redeemer for the Pyth withdraw script + val withdrawRedeemer = tx.redeemers + .get(ScriptPurpose.Rewarding(ScriptCredential(withdrawScript))) + .getOrFail("Pyth withdrawal redeemer not found") + + // 4. Parse the redeemer (List), take first update + val updates = withdrawRedeemer.toList + val updateBytes = updates.head.toByteString + + // 5. Parse price from the update + parsePythPrice(updateBytes) + } + + /** LTV check using cross-multiplication. + * + * LTV = debt / (collateral * price / ORACLE_SCALE) * 100 Rearranged: debt * LTV_SCALE * + * ORACLE_SCALE <= threshold * collateral * price + */ + def isLtvBelow( + collateralLovelace: BigInt, + price: BigInt, + debt: BigInt, + threshold: BigInt + ): Boolean = + debt * LTV_SCALE * ORACLE_SCALE <= threshold * collateralLovelace * price + + /** Decode inline datum from a transaction output, failing if it's a datum hash or missing. */ + def getInlineCdpDatum(d: OutputDatum): CdpDatum = d match + case OutputDatum.OutputDatum(data) => data.to[CdpDatum] + case _ => fail("Expected inline datum") + + /** Find the single continuing output at the script address and verify it holds the CDP NFT. + * Used by Borrow and Repay to ensure the CDP UTxO is preserved (not consumed without replacement). + */ + def getContinuingCdp( + tx: TxInfo, + cred: Credential, + scriptHash: PolicyId, + nftName: TokenName + ): TxOut = { + val output = tx + .findOwnOutputsByCredential(cred) + .oneOrFail("Must leave exactly one continuing CDP output") + require( + output.value.quantityOf(scriptHash, nftName) === BigInt(1), + "CDP NFT must be preserved" + ) + output + } + + // --- SPEND HANDLER --- + // Validates spending of an existing CDP UTxO. Each action enforces different rules: + // Borrow: owner-only, debt increases, new LTV stays under 95% + // Repay: owner-only, debt decreases, LTV checked only if collateral withdrawn + // Close: owner-only, all debt burned as PUSD, NFT burned, collateral returned + // Liquidate: permissionless, CDP must be above 90% LTV, debt burned, NFT burned + // Open: rejected here (Open is handled only by the mint handler) + inline override def spend( + param: Data, + datum: Option[Data], + redeemer: Data, + tx: TxInfo, + ownRef: TxOutRef + ): Unit = { + val params = param.to[CdpParams] + val action = redeemer.to[CdpAction] + val cdp = datum.getOrFail("Expected CDP datum").to[CdpDatum] + val ownInput = tx.findOwnInputOrFail(ownRef) + val ScriptCredential(scriptHash) = ownInput.resolved.address.credential: @unchecked + val cred = ScriptCredential(scriptHash) + + // Extract the CDP NFT name from the input being spent. + // Each CDP holds exactly one unique NFT (non-PUSD token) under the script's policy. + val inputTokens = ownInput.resolved.value.tokens(scriptHash) + require(inputTokens.size === BigInt(1), "Input must contain exactly one CDP NFT") + val nftName = inputTokens.toList.head._1 + require(inputTokens.toList.head._2 === BigInt(1), "NFT quantity must be 1") + + action match + case CdpAction.Borrow => + // Borrow: owner mints additional PUSD against existing collateral. + // The CDP must be returned with the same NFT and same owner, higher debt, + // and the new LTV must remain under MAX_LTV (95%). + require(tx.isSignedBy(cdp.owner), "Owner signature required") + val price = getPythPrice(tx, params.pythPolicyId) + val output = getContinuingCdp(tx, cred, scriptHash, nftName) + val newDatum = getInlineCdpDatum(output.datum) + require(newDatum.owner === cdp.owner, "Owner cannot change") + require(newDatum.debt > cdp.debt, "Debt must increase") + require( + isLtvBelow(output.value.getLovelace, price, newDatum.debt, MAX_LTV), + "LTV above maximum" + ) + + case CdpAction.Repay => + // Repay: owner burns PUSD to reduce debt. + // Collateral withdrawal is allowed only if the resulting LTV stays under MAX_LTV. + // If collateral stays the same or increases, no price check needed. + require(tx.isSignedBy(cdp.owner), "Owner signature required") + val output = getContinuingCdp(tx, cred, scriptHash, nftName) + val newDatum = getInlineCdpDatum(output.datum) + require(newDatum.owner === cdp.owner, "Owner cannot change") + require(newDatum.debt < cdp.debt, "Debt must decrease") + if output.value.getLovelace < ownInput.resolved.value.getLovelace then + val price = getPythPrice(tx, params.pythPolicyId) + require( + isLtvBelow(output.value.getLovelace, price, newDatum.debt, MAX_LTV), + "LTV above maximum" + ) + + case CdpAction.Close => + // Close: owner fully repays debt and reclaims all collateral. + // All PUSD debt must be burned, the CDP NFT burned, and collateral + // sent back to the owner's address (sum of all outputs to owner >= collateral). + require(tx.isSignedBy(cdp.owner), "Owner signature required") + require( + tx.mint.quantityOf(scriptHash, PUSD_TOKEN_NAME) <= -cdp.debt, + "Must burn debt in PUSD" + ) + require( + tx.mint.quantityOf(scriptHash, nftName) === BigInt(-1), + "Must burn CDP NFT" + ) + require( + tx.outputs + .filter(_.address.credential === PubKeyCredential(cdp.owner)) + .foldLeft(BigInt(0))((sum, output) => sum + output.value.getLovelace) >= + ownInput.resolved.value.getLovelace, + "All collateral must be returned to owner" + ) + + case CdpAction.Liquidate => + // Liquidate: anyone can liquidate a CDP whose LTV exceeds the 90% threshold. + // The liquidator must burn enough PUSD to cover the debt and burn the NFT. + // The collateral is released to the liquidator (enforced off-chain by the tx builder). + val price = getPythPrice(tx, params.pythPolicyId) + require( + !isLtvBelow( + ownInput.resolved.value.getLovelace, + price, + cdp.debt, + LIQ_THRESHOLD + ), + "Not liquidatable, LTV below threshold" + ) + require( + tx.mint.quantityOf(scriptHash, PUSD_TOKEN_NAME) <= -cdp.debt, + "Must burn debt in PUSD" + ) + require( + tx.mint.quantityOf(scriptHash, nftName) === BigInt(-1), + "Must burn CDP NFT" + ) + + case CdpAction.Open => + fail("Open does not spend a CDP") + } + + // --- MINT HANDLER --- + // Controls PUSD minting and burning, and CDP NFT lifecycle. + // The mint handler works in concert with the spend handler: + // - For Borrow/Repay/Close/Liquidate: cross-checks that the spend redeemer matches + // - For Open: validates new CDP creation (no existing CDP may be spent) + // This dual-validation pattern ensures both the UTxO state transition and the token + // supply change are consistent and authorized. + inline override def mint( + param: Data, + redeemer: Data, + policyId: PolicyId, + tx: TxInfo + ): Unit = { + val params = param.to[CdpParams] + val action = redeemer.to[CdpAction] + val vusdDelta = tx.mint.quantityOf(policyId, PUSD_TOKEN_NAME) + val mintedTokens = tx.mint.tokens(policyId) + // Separate NFT mints/burns from PUSD mints/burns + val nftEntries = mintedTokens.toList.filter { case (name, _) => + name !== PUSD_TOKEN_NAME + } + + action match + case CdpAction.Open => + // Open: create a fresh CDP with collateral and mint PUSD. + // Mints exactly 1 unique NFT (CDP identifier) + PUSD equal to the new debt. + // No existing CDP UTxO may be spent (prevents re-opening). + val price = getPythPrice(tx, params.pythPolicyId) + val cred = ScriptCredential(policyId) + require( + tx.findOwnInputsByCredential(cred).isEmpty, + "Open must not spend existing CDP" + ) + val nft = nftEntries.oneOrFail("Must mint exactly one CDP NFT") + require(nft._2 === BigInt(1), "Must mint exactly 1 NFT") + val output = tx + .findOwnOutputsByCredential(cred) + .oneOrFail("Must create exactly one CDP output") + val newDatum = getInlineCdpDatum(output.datum) + require( + output.value.quantityOf(policyId, nft._1) === BigInt(1), + "CDP output must hold the freshly minted NFT" + ) + require(vusdDelta === newDatum.debt, "PUSD must match debt") + require(newDatum.debt > BigInt(0), "Debt must be positive") + require( + isLtvBelow(output.value.getLovelace, price, newDatum.debt, MAX_LTV), + "LTV above maximum" + ) + + case CdpAction.Borrow => + // Borrow mint: verify PUSD delta equals the debt increase. + // Cross-checks the spend redeemer to ensure the CDP is also being spent with Borrow. + require(nftEntries.isEmpty, "Borrow must not mint/burn NFT") + val cdpInput = tx.inputs + .filter(_.resolved.address.credential === ScriptCredential(policyId)) + .oneOrFail("Must spend exactly one CDP") + val spendAction = tx.redeemers + .get(ScriptPurpose.Spending(cdpInput.outRef)) + .getOrFail("Missing spend redeemer") + .to[CdpAction] + spendAction match + case CdpAction.Borrow => () + case _ => fail("Mint and spend actions must match") + val oldDatum = getInlineCdpDatum(cdpInput.resolved.datum) + val cdpOutput = tx.outputs + .filter(_.address.credential === ScriptCredential(policyId)) + .oneOrFail("Must have continuing output") + val newDatum = getInlineCdpDatum(cdpOutput.datum) + require( + vusdDelta === newDatum.debt - oldDatum.debt, + "PUSD delta must match debt change" + ) + + case CdpAction.Repay => + // Repay mint: verify PUSD burned (negative delta) equals the debt decrease. + require(nftEntries.isEmpty, "Repay must not mint/burn NFT") + val cdpInput = tx.inputs + .filter(_.resolved.address.credential === ScriptCredential(policyId)) + .oneOrFail("Must spend exactly one CDP") + val spendAction = tx.redeemers + .get(ScriptPurpose.Spending(cdpInput.outRef)) + .getOrFail("Missing spend redeemer") + .to[CdpAction] + spendAction match + case CdpAction.Repay => () + case _ => fail("Mint and spend actions must match") + val oldDatum = getInlineCdpDatum(cdpInput.resolved.datum) + val cdpOutput = tx.outputs + .filter(_.address.credential === ScriptCredential(policyId)) + .oneOrFail("Must have continuing output") + val newDatum = getInlineCdpDatum(cdpOutput.datum) + require( + vusdDelta === newDatum.debt - oldDatum.debt, + "PUSD delta must match debt change" + ) + + case CdpAction.Close => + // Close mint: burn exactly the debt amount of PUSD and burn the CDP NFT. + val cdpInput = tx.inputs + .filter(_.resolved.address.credential === ScriptCredential(policyId)) + .oneOrFail("Must spend exactly one CDP") + val burned = nftEntries.oneOrFail("Close must burn exactly one CDP NFT") + require(burned._2 === BigInt(-1), "Close must burn exactly one CDP NFT") + val spendAction = tx.redeemers + .get(ScriptPurpose.Spending(cdpInput.outRef)) + .getOrFail("Missing spend redeemer") + .to[CdpAction] + spendAction match + case CdpAction.Close => () + case _ => fail("Mint and spend actions must match") + val oldDatum = getInlineCdpDatum(cdpInput.resolved.datum) + require(vusdDelta === -oldDatum.debt, "PUSD burn must match debt") + + case CdpAction.Liquidate => + // Liquidate mint: supports batch liquidation of multiple CDPs in one tx. + // All CDP NFTs must be burned, and total PUSD burned must equal total debt. + val cdpCred = ScriptCredential(policyId) + val cdpInputs = tx.inputs.filter(_.resolved.address.credential === cdpCred) + require(cdpInputs.nonEmpty, "Must spend at least one CDP") + require( + nftEntries.forall { case (_, qty) => qty === BigInt(-1) }, + "All CDP NFTs must be burned" + ) + require( + nftEntries.size === cdpInputs.size, + "NFT burn count must match CDP input count" + ) + val totalDebt = cdpInputs.foldLeft(BigInt(0)) { (sum, cdpInput) => + val spendAction = tx.redeemers + .get(ScriptPurpose.Spending(cdpInput.outRef)) + .getOrFail("Missing spend redeemer") + .to[CdpAction] + spendAction match + case CdpAction.Liquidate => () + case _ => fail("Mint and spend actions must match") + val oldDatum = getInlineCdpDatum(cdpInput.resolved.datum) + sum + oldDatum.debt + } + require(vusdDelta === -totalDebt, "PUSD burn must match total debt") + } +} diff --git a/lazer/cardano/pythacoin/src/main/scala/pythacoin/onchain/StrictLookups.scala b/lazer/cardano/pythacoin/src/main/scala/pythacoin/onchain/StrictLookups.scala new file mode 100644 index 00000000..d09116f8 --- /dev/null +++ b/lazer/cardano/pythacoin/src/main/scala/pythacoin/onchain/StrictLookups.scala @@ -0,0 +1,57 @@ +package pythacoin.onchain + +import scalus.cardano.onchain.plutus.prelude.PairList.{PairCons, PairNil} +import scalus.cardano.onchain.plutus.prelude.{List, PairList, SortedMap, fail} +import scalus.cardano.onchain.plutus.v3.* +import scalus.compiler.Compile +import scalus.uplc.builtin.ByteString + +import scala.annotation.tailrec + +/** On-chain utility extensions for strict (fail-fast) lookups on Plutus data structures. + * These are used by CdpValidator to assert expectations rather than returning Option, + * which keeps the on-chain code simpler and produces clear error messages on failure. + */ +@Compile +object StrictLookups { + + extension [A](self: List[A]) { + /** Find first element matching predicate, or fail the script. */ + @tailrec + def findOrFail(predicate: A => Boolean): A = self match + case List.Nil => fail("element not found") + case List.Cons(head, tail) => + if predicate(head) then head else tail.findOrFail(predicate) + + /** Assert the list has exactly one element and return it, or fail with the given message. */ + def oneOrFail(message: String): A = self match + case List.Cons(head, List.Nil) => head + case _ => fail(message) + } + + extension [V](self: Value) { + /** Look up a token quantity, failing if the policy or token name is absent. + * Unlike `quantityOf` which returns 0 for missing entries, this asserts existence. + */ + def existingQuantityOf(policyId: PolicyId, tokenName: TokenName): BigInt = { + self.toSortedMap.lookupOrFail(policyId).lookupOrFail(tokenName) + } + } + + extension [V](self: SortedMap[ByteString, V]) { + /** Look up a key in a sorted map, failing if not found. + * Exploits the sorted order to short-circuit early when key < current entry. + */ + def lookupOrFail(key: ByteString): V = { + @tailrec + def go(lst: PairList[ByteString, V]): V = lst match + case PairNil => fail("key not found") + case PairCons((k, v), tail) => + if key == k then v + else if key < k then fail("key not found") + else go(tail) + + go(self.toPairList) + } + } +} diff --git a/lazer/cardano/pythacoin/src/test/scala/pythacoin/CdpValidatorTest.scala b/lazer/cardano/pythacoin/src/test/scala/pythacoin/CdpValidatorTest.scala new file mode 100644 index 00000000..ae04e4f7 --- /dev/null +++ b/lazer/cardano/pythacoin/src/test/scala/pythacoin/CdpValidatorTest.scala @@ -0,0 +1,470 @@ +package pythacoin + +import java.time.Instant +import org.scalatest.funsuite.AnyFunSuite +import scalus.cardano.ledger.* +import scalus.cardano.onchain.plutus.prelude.{AssocMap, List} +import scalus.cardano.onchain.plutus.v1 +import scalus.cardano.onchain.plutus.v3.{ScriptContext, TxOutRef} +import scalus.cardano.txbuilder.RedeemerPurpose.{ForMint, ForSpend} +import scalus.cardano.txbuilder.txBuilder +import scalus.testing.kit.Party.{Alice, Bob} +import scalus.testing.kit.TestUtil.{genesisHash, getScriptContextV3} +import scalus.testing.kit.{ScalusTest, TestUtil} +import scalus.uplc.builtin.ByteString +import scalus.uplc.builtin.ByteString.{hex, utf8} +import scalus.uplc.builtin.Data +import scalus.uplc.builtin.Data.toData +import pythacoin.onchain.* +import pythacoin.onchain.CdpConsts.* + +class CdpValidatorTest extends AnyFunSuite, ScalusTest { + private given env: CardanoInfo = TestUtil.testEnvironment + + private val walletIn = Input(genesisHash, 0) + private val cdpIn = Input(genesisHash, 1) + private val pythIn = Input(genesisHash, 2) + private val owner = v1.PubKeyHash(Alice.addrKeyHash) + private val bobPkh = v1.PubKeyHash(Bob.addrKeyHash) + + private val nftAsset = AssetName(utf8"CDP-1") + private val collateralAda = 5_000_000_000L // 5000 ADA + private val pusdAsset = AssetName(utf8"PUSD") + private val now = Instant.parse("2026-03-07T12:00:00Z") + private val later = now.plusSeconds(30) + + // Mock Pyth policy ID + private val pythPolicyId = + ScriptHash.fromHex("aabbccddaabbccddaabbccddaabbccddaabbccddaabbccddaabbccdd") + + // Mock Pyth withdraw script hash (28 bytes = 56 hex chars) + private val pythWithdrawHash = + ScriptHash.fromHex("11223344112233441122334411223344112233441122334411223344") + + private val contract = CdpContract.withErrorTraces(pythPolicyId) + private val policyId = contract.script.scriptHash + private val scriptAddr = contract.address(env.network) + + // ADA/USD = $1.00 represented as 100_000_000 (8 decimal places, exponent = -8) + private val adaUsdPrice = 100_000_000L + + // Base datum: 2000 PUSD debt + private val baseDatum = CdpDatum(owner, BigInt(2_000_000_000L)) + + /** Build mock Pyth Solana-format update bytes. + * + * Format: [4 magic][64 sig][32 key][2 payload_size][payload] Payload: [4 magic][8 + * timestamp_us][1 channel][1 feeds_len][feed] Feed: [4 feed_id][1 props_len][prop_price] + * prop_price: [1 prop_id=0][8 price_i64_le] + */ + private def mkPriceUpdateBytes(price: Long): ByteString = { + import java.nio.{ByteBuffer, ByteOrder} + + val buf = ByteBuffer.allocate(130).order(ByteOrder.LITTLE_ENDIAN) + // Envelope: 4 magic + 64 sig + 32 key = 100 bytes + buf.putInt(0xb9011a82.toInt) // magic + buf.put(new Array[Byte](64)) // fake signature + buf.put(new Array[Byte](32)) // fake key + // Payload size (u16 LE): 24 bytes payload + buf.putShort(24.toShort) + // Payload: 4 magic + 8 timestamp + 1 channel + 1 feeds_len + 4 feed_id + 1 props + 1 prop_id + 8 price = 28 + // But we said 24, let's recalculate: 4+8+1+1 = 14 header, then feed: 4+1+1+8 = 14, but we only need 10 for feed + // Actually: feed_id(4) + props_len(1) + prop_id(1) + price(8) = 14 + // Total payload = 14 + 14 = 28? Let me just put the right size + // Let me redo: payload size should include everything after the size field + // Reset and recalculate + buf.clear() + // Envelope + buf.putInt(0xb9011a82.toInt) // 4 bytes magic + buf.put(new Array[Byte](64)) // 64 bytes signature + buf.put(new Array[Byte](32)) // 32 bytes key + // = 100 bytes so far + + // Payload: magic(4) + timestamp_us(8) + channel(1) + feeds_len(1) + feed + // Feed: feed_id(4) + props_len(1) + prop(1+8) = 14 + // Total payload = 4+8+1+1+14 = 28 + val payloadSize: Short = 28.toShort + buf.putShort(payloadSize) // 2 bytes payload size + // Payload starts at 102 + buf.putInt(0x75d3c793.toInt) // 4 bytes payload magic + buf.putLong(System.currentTimeMillis() * 1000L) // 8 bytes timestamp_us + buf.put(0.toByte) // 1 byte channel_id + buf.put(1.toByte) // 1 byte feeds_len = 1 + // Feed: ADA/USD = feed_id 16 + buf.putInt(16) // 4 bytes feed_id + buf.put(1.toByte) // 1 byte props_len = 1 + // Property 0 = Price (I64 LE) + buf.put(0.toByte) // 1 byte prop_id = 0 + buf.putLong(price) // 8 bytes price + // Total: 100 + 2 + 28 = 130 bytes + ByteString.unsafeFromArray(buf.array().take(buf.position())) + } + + /** Build mock Pyth State datum as raw Data. + * + * Pyth datum is: Constr(0, [governance, trusted_signers, deprecated_withdraw_scripts, + * withdraw_script]) + */ + private def mkPythStateDatum(): Data = { + import scalus.cardano.onchain.plutus.prelude.List as PList + // governance: Constr(0, [policy_id, emitter_chain, emitter_address, seen_sequence]) + val governance = Data.Constr( + 0, + PList( + Data.B(ByteString.fill(28, 0)), + Data.I(BigInt(0)), + Data.B(ByteString.empty), + Data.I(BigInt(0)) + ) + ) + // trusted_signers: empty map + val trustedSigners = Data.Map(PList.empty) + // deprecated_withdraw_scripts: empty map + val deprecated = Data.Map(PList.empty) + // withdraw_script: the script hash bytes + val withdrawScript = Data.B(pythWithdrawHash: ByteString) + + Data.Constr(0, PList(governance, trustedSigners, deprecated, withdrawScript)) + } + + /** Build mock Pyth withdrawal redeemer: List containing one price update */ + private def mkPythRedeemer(price: Long): Data = { + import scalus.cardano.onchain.plutus.prelude.List as PList + val updateBytes = mkPriceUpdateBytes(price) + Data.List(PList(Data.B(updateBytes))) + } + + private def mkWallet(ada: Long): Utxo = + Utxo(walletIn, Output(Alice.address, Value.lovelace(ada))) + + private def mkBobWallet(ada: Long): Utxo = + Utxo(Input(genesisHash, 3), Output(Bob.address, Value.lovelace(ada))) + + private def mkPythState(): Utxo = + Utxo( + pythIn, + Output( + Alice.address, + Value.ada(2) + Value.asset(pythPolicyId, AssetName(utf8"Pyth State"), 1L), + mkPythStateDatum() + ) + ) + + private def mkCdp(datum: CdpDatum = baseDatum, collateral: Long = collateralAda): Utxo = + Utxo( + cdpIn, + Output( + scriptAddr, + Value.lovelace(collateral) + Value.asset(policyId, nftAsset, 1L), + datum.toData + ) + ) + + private def withOwnerSignature(ctx: ScriptContext) = + ctx.copy( + txInfo = ctx.txInfo.copy( + signatories = List(owner) + ) + ) + + private def withBobSignature(ctx: ScriptContext) = + ctx.copy( + txInfo = ctx.txInfo.copy( + signatories = List(bobPkh) + ) + ) + + private def withPythWithdrawal(ctx: ScriptContext, price: Long): ScriptContext = { + import scalus.cardano.onchain.plutus.v1.Credential.ScriptCredential + val redeemer = mkPythRedeemer(price) + val withdrawalKey = ScriptCredential(pythWithdrawHash) + ctx.copy( + txInfo = ctx.txInfo.copy( + redeemers = ctx.txInfo.redeemers.insert( + scalus.cardano.onchain.plutus.v3.ScriptPurpose.Rewarding(withdrawalKey), + redeemer + ) + ) + ) + } + + test(s"CDP contract size is ${CdpContract.base.script.script.size}") { + assert(CdpContract.base.script.script.size < 16384, "Contract must be under 16KB") + } + + test("open: valid CDP") { + val pythUtxo = mkPythState() + val utxos = Utxos(mkWallet(10_000_000_000L), pythUtxo) + val tx = txBuilder + .references(pythUtxo) + .validFrom(now) + .validTo(later) + .mint( + contract, + Map(nftAsset -> 1L, pusdAsset -> baseDatum.debt.toLong), + CdpAction.Open + ) + .payTo( + scriptAddr, + Value.lovelace(collateralAda) + Value.asset(policyId, nftAsset, 1L), + baseDatum.toData + ) + .draft + + val ctx = withPythWithdrawal( + tx.getScriptContextV3(utxos, ForMint(policyId)), + adaUsdPrice + ) + val result = contract.eval(ctx.toData) + assert(result.isSuccess, s"${result.logs.mkString(", ")} | $result") + } + + test("open: over 95% LTV rejected") { + // 5000 ADA at $1 = $5000 collateral, 95% LTV = $4750 max debt + // Try to borrow $4800 = 4_800_000_000 PUSD + val overLeveragedDatum = CdpDatum(owner, BigInt(4_800_000_000L)) + val pythUtxo = mkPythState() + val utxos = Utxos(mkWallet(10_000_000_000L), pythUtxo) + val tx = txBuilder + .references(pythUtxo) + .validFrom(now) + .validTo(later) + .mint( + contract, + Map(nftAsset -> 1L, pusdAsset -> overLeveragedDatum.debt.toLong), + CdpAction.Open + ) + .payTo( + scriptAddr, + Value.lovelace(collateralAda) + Value.asset(policyId, nftAsset, 1L), + overLeveragedDatum.toData + ) + .draft + + val ctx = withPythWithdrawal( + tx.getScriptContextV3(utxos, ForMint(policyId)), + adaUsdPrice + ) + val result = contract.eval(ctx.toData) + assert(result.isFailure) + assert(result.logs.exists(_.contains("LTV above maximum")), result.logs.mkString(", ")) + } + + test("borrow: increase debt within 95% LTV") { + val pythUtxo = mkPythState() + val cdpUtxo = mkCdp() + val borrowAmount = 500_000_000L + val newDatum = baseDatum.copy(debt = baseDatum.debt + borrowAmount) + val utxos = Utxos(mkWallet(10_000_000_000L), cdpUtxo, pythUtxo) + val tx = txBuilder + .spend(cdpUtxo, CdpAction.Borrow, contract) + .references(pythUtxo) + .validFrom(now) + .validTo(later) + .mint(contract, Map(pusdAsset -> borrowAmount), CdpAction.Borrow) + .payTo( + scriptAddr, + Value.lovelace(collateralAda) + Value.asset(policyId, nftAsset, 1L), + newDatum.toData + ) + .draft + + val ctx = withPythWithdrawal( + withOwnerSignature(tx.getScriptContextV3(utxos, ForSpend(cdpIn))), + adaUsdPrice + ) + val result = contract.eval(ctx.toData) + assert(result.isSuccess, s"${result.logs.mkString(", ")} | $result") + } + + test("borrow: exceed 95% LTV rejected") { + val pythUtxo = mkPythState() + val cdpUtxo = mkCdp() + // Try to borrow up to 4800 PUSD total (96% LTV) + val borrowAmount = 2_800_000_000L + val newDatum = baseDatum.copy(debt = baseDatum.debt + borrowAmount) + val utxos = Utxos(mkWallet(10_000_000_000L), cdpUtxo, pythUtxo) + val tx = txBuilder + .spend(cdpUtxo, CdpAction.Borrow, contract) + .references(pythUtxo) + .validFrom(now) + .validTo(later) + .mint(contract, Map(pusdAsset -> borrowAmount), CdpAction.Borrow) + .payTo( + scriptAddr, + Value.lovelace(collateralAda) + Value.asset(policyId, nftAsset, 1L), + newDatum.toData + ) + .draft + + val ctx = withPythWithdrawal( + withOwnerSignature(tx.getScriptContextV3(utxos, ForSpend(cdpIn))), + adaUsdPrice + ) + val result = contract.eval(ctx.toData) + assert(result.isFailure) + assert(result.logs.exists(_.contains("LTV above maximum")), result.logs.mkString(", ")) + } + + test("borrow: non-owner rejected") { + val pythUtxo = mkPythState() + val cdpUtxo = mkCdp() + val borrowAmount = 500_000_000L + val newDatum = baseDatum.copy(debt = baseDatum.debt + borrowAmount) + val utxos = Utxos(mkWallet(10_000_000_000L), cdpUtxo, pythUtxo) + val tx = txBuilder + .spend(cdpUtxo, CdpAction.Borrow, contract) + .references(pythUtxo) + .validFrom(now) + .validTo(later) + .mint(contract, Map(pusdAsset -> borrowAmount), CdpAction.Borrow) + .payTo( + scriptAddr, + Value.lovelace(collateralAda) + Value.asset(policyId, nftAsset, 1L), + newDatum.toData + ) + .draft + + val ctx = withPythWithdrawal( + withBobSignature(tx.getScriptContextV3(utxos, ForSpend(cdpIn))), + adaUsdPrice + ) + val result = contract.eval(ctx.toData) + assert(result.isFailure) + assert( + result.logs.exists(_.contains("Owner signature required")), + result.logs.mkString(", ") + ) + } + + test("repay: reduce debt") { + val pythUtxo = mkPythState() + val cdpUtxo = mkCdp() + val repayAmount = 500_000_000L + val newDatum = baseDatum.copy(debt = baseDatum.debt - repayAmount) + val utxos = Utxos(mkWallet(10_000_000_000L), cdpUtxo, pythUtxo) + val tx = txBuilder + .spend(cdpUtxo, CdpAction.Repay, contract) + .references(pythUtxo) + .validFrom(now) + .validTo(later) + .mint(contract, Map(pusdAsset -> -repayAmount), CdpAction.Repay) + .payTo( + scriptAddr, + Value.lovelace(collateralAda) + Value.asset(policyId, nftAsset, 1L), + newDatum.toData + ) + .draft + + val ctx = withPythWithdrawal( + withOwnerSignature(tx.getScriptContextV3(utxos, ForSpend(cdpIn))), + adaUsdPrice + ) + val result = contract.eval(ctx.toData) + assert(result.isSuccess, s"${result.logs.mkString(", ")} | $result") + } + + test("close: full repayment, NFT burned, ADA returned") { + val pythUtxo = mkPythState() + val cdpUtxo = mkCdp() + val utxos = Utxos(mkWallet(10_000_000_000L), cdpUtxo, pythUtxo) + val tx = txBuilder + .spend(cdpUtxo, CdpAction.Close, contract) + .references(pythUtxo) + .validFrom(now) + .validTo(later) + .mint( + contract, + Map(nftAsset -> -1L, pusdAsset -> -baseDatum.debt.toLong), + CdpAction.Close + ) + .payTo(Alice.address, Value.lovelace(collateralAda)) + .draft + + val ctx = withOwnerSignature(tx.getScriptContextV3(utxos, ForSpend(cdpIn))) + val result = contract.eval(ctx.toData) + assert(result.isSuccess, s"${result.logs.mkString(", ")} | $result") + } + + test("close: non-owner rejected") { + val pythUtxo = mkPythState() + val cdpUtxo = mkCdp() + val utxos = Utxos(mkWallet(10_000_000_000L), cdpUtxo, pythUtxo) + val tx = txBuilder + .spend(cdpUtxo, CdpAction.Close, contract) + .references(pythUtxo) + .validFrom(now) + .validTo(later) + .mint( + contract, + Map(nftAsset -> -1L, pusdAsset -> -baseDatum.debt.toLong), + CdpAction.Close + ) + .payTo(Bob.address, Value.lovelace(collateralAda)) + .draft + + val ctx = withBobSignature(tx.getScriptContextV3(utxos, ForSpend(cdpIn))) + val result = contract.eval(ctx.toData) + assert(result.isFailure) + assert( + result.logs.exists(_.contains("Owner signature required")), + result.logs.mkString(", ") + ) + } + + test("liquidate: LTV > 90% succeeds") { + // ADA price drops to $0.50 -> 5000 ADA = $2500 collateral, $2000 debt = 80% LTV + // But at $0.40 -> 5000 ADA = $2000 collateral, $2000 debt = 100% LTV > 90% + val lowPrice = 40_000_000L // $0.40 + val pythUtxo = mkPythState() + val cdpUtxo = mkCdp() + val utxos = Utxos(mkBobWallet(10_000_000_000L), cdpUtxo, pythUtxo) + val tx = txBuilder + .spend(cdpUtxo, CdpAction.Liquidate, contract) + .references(pythUtxo) + .validFrom(now) + .validTo(later) + .mint( + contract, + Map(nftAsset -> -1L, pusdAsset -> -baseDatum.debt.toLong), + CdpAction.Liquidate + ) + .draft + + val ctx = withPythWithdrawal( + tx.getScriptContextV3(utxos, ForSpend(cdpIn)), + lowPrice + ) + val result = contract.eval(ctx.toData) + assert(result.isSuccess, s"${result.logs.mkString(", ")} | $result") + } + + test("liquidate: LTV <= 90% rejected") { + // ADA at $1.00 -> 5000 ADA = $5000 collateral, $2000 debt = 40% LTV (healthy) + val pythUtxo = mkPythState() + val cdpUtxo = mkCdp() + val utxos = Utxos(mkBobWallet(10_000_000_000L), cdpUtxo, pythUtxo) + val tx = txBuilder + .spend(cdpUtxo, CdpAction.Liquidate, contract) + .references(pythUtxo) + .validFrom(now) + .validTo(later) + .mint( + contract, + Map(nftAsset -> -1L, pusdAsset -> -baseDatum.debt.toLong), + CdpAction.Liquidate + ) + .draft + + val ctx = withPythWithdrawal( + tx.getScriptContextV3(utxos, ForSpend(cdpIn)), + adaUsdPrice + ) + val result = contract.eval(ctx.toData) + assert(result.isFailure) + assert( + result.logs.exists(_.contains("Not liquidatable")), + result.logs.mkString(", ") + ) + } +} diff --git a/lazer/cardano/pythacoin/src/test/scala/pythacoin/TestExtensions.scala b/lazer/cardano/pythacoin/src/test/scala/pythacoin/TestExtensions.scala new file mode 100644 index 00000000..70f129e7 --- /dev/null +++ b/lazer/cardano/pythacoin/src/test/scala/pythacoin/TestExtensions.scala @@ -0,0 +1,10 @@ +package pythacoin + +import scalus.uplc.PlutusV3 +import scalus.uplc.builtin.Data +import scalus.uplc.eval.{PlutusVM, Result} + +extension (contract: PlutusV3[Data => Unit]) { + def eval(ctxData: Data)(using PlutusVM): Result = + (contract.program $ ctxData).evaluateDebug +}