diff --git a/lazer/cardano/guards/README.md b/lazer/cardano/guards/README.md new file mode 100644 index 00000000..80cc5285 --- /dev/null +++ b/lazer/cardano/guards/README.md @@ -0,0 +1,76 @@ +# Team Guards Pythathon Submission + +## Details + +Team Name: Guards +Submission Name: Guards One +Team Members: @f0x1777 @kevan1 @joaco05 +Contact: @f0x1777 + +## Project Description + +Guards is a multichain treasury protection workflow that uses Pyth price feeds to monitor liquid portfolio value, stable-denominated floors, and oracle-aware de-risking conditions. This submission focuses on the Cardano deployment surface. + +This is the initial draft submission only. Source code, setup steps, and the full demo flow will be added incrementally in later updates to this draft PR. + +## Testing & Verification + +### How to Test This Contribution + +The current implementation can be verified in three layers: + +1. Static checks: typecheck and unit/integration tests +2. Local simulation: deterministic breach -> de-risk -> exit -> recovery flow +3. Live oracle wiring: fetch a real signed Pyth update for Cardano preprod + +### Prerequisites + +- Node.js `>= 24.0.0` +- `pnpm` +- A `.env` file based on `.env.example` +- A valid `PYTH_API_KEY` +- Cardano preprod configuration for live verification: + - `PYTH_PREPROD_POLICY_ID` + - `CARDANO_BLOCKFROST_PROJECT_ID` + - `CARDANO_PYTH_STATE_REFERENCE` + +### Setup & Run Instructions + +```bash +pnpm install +cp .env.example .env +``` + +Fill the required environment variables, then run: + +```bash +# Static validation +pnpm typecheck +pnpm test + +# Deterministic treasury simulation +pnpm simulate + +# Fetch a live signed Pyth update for the Cardano preprod flow +pnpm pyth:fetch-live + +# Run the UI locally +pnpm --dir apps/ui dev +``` + +Local UI routes: + +- Landing: `http://localhost:3000` +- Dashboard: `http://localhost:3000/dashboard` + +### Deployment Information + +Current deployment target for the hackathon is **Cardano preprod**. + +Relevant deployment/runtime notes: + +- Pyth preprod policy id: + `d799d287105dea9377cdf9ea8502a83d2b9eb2d2050a8aea800a21e6` +- Frontend can be deployed from `apps/ui` +- Off-chain oracle/keeper services require a Node 24 runtime plus the Cardano/Pyth environment variables listed above +- The multichain control-plane logic is shared, but this submission's live execution path is centered on Cardano preprod diff --git a/lazer/cardano/guards/source/.env.example b/lazer/cardano/guards/source/.env.example new file mode 100644 index 00000000..f695a36d --- /dev/null +++ b/lazer/cardano/guards/source/.env.example @@ -0,0 +1,45 @@ +APP_PUBLIC_NAME=guards.one +APP_INTERNAL_NAME=anaconda +PORT=4310 + +PYTH_API_KEY=replace_with_pyth_api_key +PYTH_PREPROD_POLICY_ID=d799d287105dea9377cdf9ea8502a83d2b9eb2d2050a8aea800a21e6 +PYTH_API_BASE_URL=https://api.pyth.network +PYTH_METADATA_SERVICE_URL= +PYTH_PRICE_SERVICE_URL= +PYTH_STREAM_CHANNEL=fixed_rate@200ms +PYTH_PRIMARY_FEED_ID=pyth-ada-usd +PYTH_PRIMARY_SYMBOL_QUERY=ADA/USD +PYTH_PRIMARY_ASSET_TYPE=crypto +PYTH_PRIMARY_PRICE_FEED_ID= +CARDANO_PYTH_STATE_REFERENCE=replace_with_pyth_state_reference_utxo + +CARDANO_NETWORK=preprod +CARDANO_TESTNET_MAGIC=1 +CARDANO_PROVIDER=blockfrost +CARDANO_PROVIDER_URL=https://cardano-preprod.blockfrost.io/api/v0 +CARDANO_BLOCKFROST_PROJECT_ID=replace_with_blockfrost_project_id +POLICY_VAULT_SCRIPT_FILE=./apps/blockchain/cardano/contracts/aiken/artifacts/policy_vault.plutus +POLICY_VAULT_ADDRESS_FILE=./apps/blockchain/cardano/contracts/aiken/artifacts/policy_vault.addr +CARDANO_SWAP_PROVIDER=dexhunter +CARDANO_PROTOCOL_FEE_BPS=30 +CARDANO_MAX_TOTAL_FEE_BPS=100 +CARDANO_PROTOCOL_FEE_MODE=explicit_output +CARDANO_EXECUTION_ROUTE_ID=cardano-minswap-ada-usdm +DEXHUNTER_BASE_URL=https://api-us.dexhunterv3.app +DEXHUNTER_PARTNER_ID=replace_with_dexhunter_partner_id +DEXHUNTER_PARTNER_FEE_PERCENT=0.3 +MINSWAP_AGGREGATOR_URL=https://agg-api.minswap.org/aggregator +MINSWAP_PARTNER_CODE=replace_with_minswap_partner_code +CARDANO_EXECUTION_HOT_WALLET_ADDRESS=replace_with_execution_hot_wallet +CARDANO_EXECUTION_HOT_WALLET_SKEY_PATH=./secrets/execution-hot.skey +CARDANO_GOVERNANCE_WALLET_ADDRESS=replace_with_governance_wallet +CARDANO_GOVERNANCE_SKEY_PATH=./secrets/governance.skey + +AUDIT_DB_PATH=./data/guards-one.sqlite + +# Image generation (Nano Banana 2 / Gemini) +GEMINI_API_KEY=replace_with_gemini_api_key + +# Replicate API +REPLICATE_API_TOKEN=replace_with_replicate_api_token diff --git a/lazer/cardano/guards/source/.github/copilot-instructions.md b/lazer/cardano/guards/source/.github/copilot-instructions.md new file mode 100644 index 00000000..0ca8ff8b --- /dev/null +++ b/lazer/cardano/guards/source/.github/copilot-instructions.md @@ -0,0 +1,12 @@ +# Copilot Review Instructions + +Review this repository as a treasury automation system where oracle correctness and bounded execution matter more than stylistic preferences. + +Focus on: +- risk-ladder invariants across `Normal -> Watch -> Partial DeRisk -> Full Stable Exit -> Frozen`, plus the `Auto Re-entry` recovery path +- oracle guardrails: stale data rejection, confidence thresholds, cooldown handling, and deterministic use of snapshots +- execution safety: approved routes only, bounded sell amounts, min-buy enforcement, reason hashes, and reproducible audit trails +- multichain discipline: shared core logic must remain chain-agnostic and adapters must not leak Cardano-specific assumptions back into `packages/core` +- tests: missing edge cases, regressions in partial de-risk sizing, full exit behavior, or re-entry hysteresis + +Prioritize bugs, broken invariants, security gaps, and missing tests. De-prioritize cosmetic refactors unless they affect correctness or maintainability. diff --git a/lazer/cardano/guards/source/.gitignore b/lazer/cardano/guards/source/.gitignore new file mode 100644 index 00000000..e9cced57 --- /dev/null +++ b/lazer/cardano/guards/source/.gitignore @@ -0,0 +1,10 @@ +node_modules/ +.DS_Store +.pnpm-store/ +*.log +.env +.env.* +!.env.example +secrets/ +*.sqlite +apps/blockchain/cardano/contracts/aiken/build/ diff --git a/lazer/cardano/guards/source/INTERNAL_NOTES.md b/lazer/cardano/guards/source/INTERNAL_NOTES.md new file mode 100644 index 00000000..9ea94ca0 --- /dev/null +++ b/lazer/cardano/guards/source/INTERNAL_NOTES.md @@ -0,0 +1,137 @@ +# Internal Notes + +> This document captures internal context, architectural decisions, and development history that are useful for the team but not relevant to the public-facing README. Keep it updated as the project evolves. + +--- + +## Origin + +Guards was originally built for the **Buenos Aires Pythathon** (Pyth Network hackathon). The challenge was to demonstrate meaningful use of Pyth oracle data beyond simple price feeds. We chose treasury risk management because it requires multiple oracle signals (price, EMA, confidence, freshness) and turns them into real execution decisions — not just charts. + +The public branding is `guards.one`. The internal package namespace is `anaconda` (the original project codename). + +## Why Treasury Risk Management + +We evaluated several directions before settling on treasury policy enforcement: + +- **Price alerts** — too simple, no execution surface +- **DEX aggregation** — competitive space, oracle usage is shallow +- **Lending protocol** — requires deep on-chain infra, too much scope for a hackathon +- **Treasury autopilot** — needs multiple oracle signals, has a clear execution model, and solves a real problem for DAOs + +The insight was that DAOs define risk in percentages but fail when absolute fiat floors are breached. Most treasury tooling is passive. Guards makes it active. + +## Key Architectural Decisions + +### Chain-agnostic core from day one + +Even though Cardano was the only live target, we built `packages/core` with zero chain imports. This was intentional: if the core engine depends on Cardano types, adding Solana or EVM later requires a refactor. By isolating the business logic early, each chain adapter only needs to implement `TreasuryConnector`. + +**Trade-off:** More upfront abstraction work. Worth it for multichain credibility. + +### Split custody model + +We deliberately avoided the pattern where automated bots spend from the governance multisig. Instead, governance pre-approves a bounded execution bucket. The keeper can only swap within policy limits from the hot wallet. This is safer and more realistic for production DAOs. + +### Risk ladder over binary modes + +Early versions had a simple "safe/risky" toggle. We replaced it with a 6-stage risk ladder (Normal → Watch → Partial De-Risk → Full Exit → Frozen → Re-Entry) because: +- Gradual escalation reduces unnecessary trading +- Frozen state handles oracle quality degradation +- Re-entry with hysteresis prevents oscillation +- Each stage is explainable to auditors and governance + +### DexHunter as primary venue + +Chosen over Minswap because DexHunter offers: +- Partner fee infrastructure (revenue capture) +- Aggregated routing across Cardano DEXs +- API-first design suitable for automated execution + +Minswap is kept as fallback. The venue layer is abstracted so adding new venues is straightforward. + +### Static UI → Next.js migration plan + +The repository currently serves the operator demo through the static preview flow. A richer Next.js UI exists as a parallel feature branch and is intended to replace the static shell once it lands. + +Why that migration still matters: +- component-based architecture +- stricter type safety around the operator surface +- better developer experience for larger UI changes +- a cleaner path to a production-ready build pipeline + +Design direction: premium dark theme inspired by [squads.xyz](https://squads.xyz), with electric blue (#3b82f6) accents on deep black (#08090c). The goal is to feel like institutional-grade treasury tooling, not a hackathon project. + +## Development Workflow + +### Worktree-based feature branches + +We use `git worktree` for parallel feature development. Each feature gets its own directory: + +```bash +git worktree add ../anaconda- feature/ +``` + +This allows working on multiple features simultaneously without branch switching. + +### PR review flow + +- Every feature branch gets a PR against `main` +- Copilot review is requested on each PR +- Review comments are tracked in `NEXT_STEPS.md` + +### Monorepo scripts + +| Script | Purpose | +|--------|---------| +| `pnpm test` | Run vitest across all packages | +| `pnpm typecheck` | TypeScript strict mode check | +| `pnpm simulate` | End-to-end backend simulation | +| `pnpm export:ui-data` | Generate demo state JSON for UI | +| `pnpm preview` | Backend server + operator demo at :4310 | + +## PR History & Review Tracker + +| PR | Scope | Status | +|----|-------|--------| +| #1 | Documentation bootstrap | Merged | +| #2 | Core policy engine | Merged | +| #3 | SVM + EVM scaffolds | Merged | +| #4 | Cardano PolicyVault simulator | Merged | +| #5 | Backend orchestration + legacy UI | Merged | +| #7 | Cardano swap venue strategy | Merged | +| #8 | Protocol fee model | Merged | +| #9 | Premium UI overhaul (Next.js) | Open | +| #10 | Cardano custody model | Merged | +| #12 | DexHunter live adapter | Merged | + +Detailed review items and their resolution are tracked in [NEXT_STEPS.md](./NEXT_STEPS.md). + +## Tooling & AI-Assisted Development + +### Skills & MCP integrations + +| Tool | Purpose | Config | +|------|---------|--------| +| Nano Banana 2 | Logo/image generation via Gemini 3.1 Flash | `~/tools/nano-banana-2`, key in `~/.nano-banana/.env` | +| 21st.dev Magic | AI-powered UI component generation | MCP in `~/.claude.json` | +| Replicate | Additional AI model access | API token in `.env` | + +### Generated assets + +- `apps/ui/public/guards-logo.png` — Shield + "GUARDS" wordmark, white on transparent background. Used across navbar, sidebar, footer, and README. Favicon is configured separately as `/guards-icon.svg`. + +## Pending Work + +See [NEXT_STEPS.md](./NEXT_STEPS.md) for the full backlog. Key priorities: + +1. **Wire real Cardano wallet** into DexHunter execution path +2. **Port PolicyVault to Aiken** (on-chain validator) +3. **Integrate real Pyth signed updates** on preprod +4. **Surface fee breakdown** in operator UI +5. **Add CI** for tests and typecheck +6. **Deploy storage** — replace SQLite with production-grade persistence + +## Team & Contact + +This project is maintained by the SOLx-AR team. For questions or collaboration, reach out through the GitHub organization. diff --git a/lazer/cardano/guards/source/NEXT_STEPS.md b/lazer/cardano/guards/source/NEXT_STEPS.md new file mode 100644 index 00000000..ea4e0231 --- /dev/null +++ b/lazer/cardano/guards/source/NEXT_STEPS.md @@ -0,0 +1,187 @@ +# NEXT STEPS + +This is the canonical tracker for the repository. Every new task, bug, review comment, or deployment prerequisite should be added here and checked off when done. + +## Rules +- Update this file in the same commit that changes the underlying work. +- Keep items concrete and testable. +- If something is blocked, note the blocker inline instead of deleting the task. + +## Current Review Pass + +### PR · Landing Custody / Footer +- [x] Clarify that the custody model supports native rails and external multisig integrations like Squads and Safe +- [x] Remove the public GitHub CTA from the landing page +- [x] Upgrade the footer so it acts as product navigation and runtime summary instead of a thin status strip +- [x] Remove the standalone custody/security section and absorb the important point into the main product flow +- [x] Merge the old How It Works and Risk Ladder explanations into one simpler section +- [x] Remove redundant landing explainer sections that were adding more detail than value + +### PR · Scaffold Sweep +- [x] Replace residual weak wording in the Aiken scaffold with explicit `scaffold` language +- [x] Port additional pure PolicyVault invariants from the TypeScript simulator into the Aiken helper module +- [x] Reword the pending `pyth-examples` tracker item so it refers to filling real team/contact fields + +### PR #14 · Apps Blockchain Surface +- [x] Make manifest paths stable regardless of the caller's current working directory +- [x] Upgrade the manifest tests from substring checks to real filesystem existence checks + +### PR #13 · README / Internal Notes +- [x] Align the public Quick Start with the scripts that actually exist in `main` +- [x] Remove the public README link to internal-only notes +- [x] Reword the Next.js section in `INTERNAL_NOTES.md` as a planned migration, not a merged reality +- [x] Remove non-existent root UI scripts from the internal workflow table +- [x] Keep internal asset references aligned with the committed `apps/ui/assets/*.jpeg` files + +### PR #12 · DexHunter Live Adapter +- [x] Add a DexHunter API client for estimate/build/sign with partner header auth +- [x] Add a live keeper path that executes from the hot wallet via injected signer/submission hooks +- [x] Record fee breakdown in live execution audit events +- [x] Use DexHunter build totals, not `maxSellAmount`, when recording sold amount and average price +- [x] Refuse live execution when the intent is expired or not yet valid +- [x] Remove unused `CARDANO_STABLE_TOKEN_ID` config and align docs with the actual per-call inputs + +### PR #11 · Cardano Aiken Scaffold +- [x] Replace bytearray stub calls in the validator entrypoint with fail-closed scaffold branches +- [x] Make unimplemented admin paths fail closed instead of returning `True` +- [x] Remove hard-coded Pyth policy id literals from the policy helper and compare against datum-carried config instead +- [x] Align scaffolded execution intent/result shapes more closely with the canonical TypeScript model +- [x] Replace absolute local README links with repo-relative links +- [x] Make the cooldown check reflect the intended invariant instead of a no-op timestamp comparison +- [x] Rename the stage-transition helper to document monotonic escalation instead of an identity mapping + +### PR #10 · Cardano Custody Model +- [x] Clarify that Tx 1 records the execution intent via current metadata/datum surfaces rather than promising an explicit on-chain intent artifact today + +### PR #8 · Protocol Fee Model +- [x] Nest the markdown lists correctly in the protocol-fee doc +- [x] Use explicit percent-point naming in the fee model to match the venue-config layer +- [x] Simplify fee amount rounding with numeric `toFixed` conversions + +### PR #7 · Cardano Venue Strategy +- [x] Normalize swap-provider config parsing so env values are whitespace/case tolerant +- [x] Bound venue/protocol fee inputs and reject non-finite values by clamping to safe ranges +- [x] Make fee conversion semantics explicit with both percent-points and rate helpers +- [x] Remove unrelated example secrets from `.env.example` + +### PR #9 · Premium UI Overhaul +- [x] Remove brittle `stageColor(...).split(" ")` usage and centralize `RiskStage` presentation helpers +- [x] Derive swap haircut display/math from `policy.haircutBps` +- [x] Guard simulation replay against empty frame sets and improve replay accessibility labels +- [x] Reformat `apps/ui/package.json` and add explicit UI typecheck coverage to the root pipeline +- [x] Normalize demo oracle timestamps to milliseconds and add UI-safe fallbacks for optional data +- [x] Keep the replay/demo deterministic for SSR by using fixed timestamps and a stable reference clock +- [x] Split UI ladder highlighting from raw `RiskStage` so `auto_reentry` can render distinctly from `normal` +- [x] Reuse core-derived domain types in the UI view model instead of duplicating oracle/policy primitives +- [x] Guard swap estimates and replay chart math against invalid oracle prices / zero balances +- [x] Correct UI copy that implied live Pyth integration on Cardano before the real witness path exists +- [x] Separate ladder step identity from `RiskStage` so `auto_reentry` does not alias `normal` +- [x] Point the root `preview` workflow at the Next.js app and keep the static preview server as `preview:legacy` +- [x] Declare the Node runtime floor required by Next 16 / Tailwind 4 and add `baseUrl` for UI path mapping +- [x] Align the `full_exit` label with the rest of the UI as `Full Stable Exit` +- [x] Run root UI scripts through `pnpm --dir apps/ui` so the Next.js binary resolves correctly +- [x] Give policy cards an actual border width so accent border colors render +- [x] Add an explicit `Frozen` ladder step so the UI can represent every `RiskStage` +- [x] Rebase `feature/premium-ui-overhaul` onto `main`, resolve review threads, and re-request Copilot review + +### PR #1 · Docs +- [x] Align README wording with the documentation-only state of the branch +- [x] Standardize risk-ladder terminology across README, functional spec, and Copilot instructions +- [x] Clarify `ema_price` vs `emaPrice` +- [x] Clarify the "two-transaction flow" wording in the frontend spec + +### PR #2 · Core +- [x] Value stable assets using oracle price when available so depegs affect portfolio math +- [x] Enforce route `chainId` and `live` status in route selection +- [x] Sort snapshot IDs deterministically before hashing/auditing +- [x] Remove root scripts that reference apps before those workspaces exist +- [x] Regenerate the lockfile/workspace state to match the branch contents +- [x] Add a depeg regression test for stable valuation + +### PR #3 · Multichain +- [x] Reuse core shared types instead of duplicating multichain primitives +- [x] Align SVM/EVM stage naming with the core risk ladder +- [x] Validate simulation inputs before computing outputs +- [x] Add invalid-pricing regression tests for SVM and EVM + +### PR #4 · Cardano +- [x] Replace fragile `try/catch` tests with assertions that fail when no error is thrown +- [x] Enforce `result.routeId === intent.routeId` +- [x] Derive tx validity from intent/policy instead of hardcoded constants +- [x] Reject authorization while an intent is already in flight +- [x] Wire `pythStateReference` into the tx envelope +- [x] Reuse `intent.reasonHash` in the tx metadata +- [x] Validate vault, chain, execution time window, and max sell bounds on completion +- [x] Make simulated `averagePrice` consistent with the trade math + +### PR #5 · Apps +- [x] Fix invalid CSS `min()` usage with `calc()` +- [x] Replace broken local docs links in the UI with a safe repo/demo strategy +- [x] Align fallback chip tokens with existing styles +- [x] Escape or avoid unsafe HTML injection in the UI renderer +- [x] Remove the unused backend connector field +- [x] Make audit event ordering deterministic +- [x] Harden preview path resolution against traversal and absolute-path bugs +- [x] Avoid crashing when `Host` is missing in the preview server + +### PR #6 · UI Demo +- [x] Make the watch frame deterministic by using a separate watch-only snapshot before partial de-risk +- [x] Validate ladder tone tokens before interpolating them into CSS class names + +### PR #15 · Aiken Scaffold Sweep +- [x] Tighten scaffold intent-shape validation so `max_sell_amount` and `min_buy_amount` must be strictly positive +- [x] Require `datum.current_intent_id == Some(intent.intent_id)` before scaffold completion accepts an execution result + +### PR #31 · Live Multi-Asset Quotes +- [x] Clear stale live-quote state when the dashboard falls back after a fetch failure +- [x] Cache the Pyth Lazer client and resolved price feed ids in the quotes route +- [x] Enforce oracle freshness and confidence guardrails before applying live ADA quotes to the dashboard state +- [x] Disable manual editing of the reference-price field while a live reference quote is active +- [x] Add unit tests for live-quote hydration and guardrail fallback behavior + +## Product / Stack Pending +- [x] Show a clear preprod-only / mainnet-unavailable warning as soon as the app opens +- [x] Document the real preprod vault bootstrap path and its current blockers +- [x] Add a browser-side vault bootstrap lab to the dashboard for custody, thresholds, and reference-target planning +- [x] Add a scenario lab that runs the real risk engine against interactive price and balance inputs +- [x] Add a runtime control panel that switches between mock replay and the preprod-oriented operator snapshot +- [x] Add mock wallet-session scaffolding for Cardano and SVM demo flows +- [x] Add a 7d / 15m historical replay that executes treasury strategies against deterministic mock price states +- [x] Move wallet connect into the dashboard topbar with real-provider detection plus mock fallback +- [x] Replace execution-side multichain readiness copy with active vault/company profile context +- [x] Source ADA and reference-asset quotes from live Pyth snapshots in the dashboard when server credentials are available +- [x] Move runtime controls into a dedicated sidebar-accessible dashboard section instead of rendering them on every view +- [x] Bootstrap root `.env` and `.env.example` for Pyth/Cardano preprod and deploy settings +- [x] Add `apps/blockchain` as the team-facing collaboration surface for contracts and chain adapters +- [x] Evaluate Cardano aggregator options for integrator fee capture and choose a primary venue +- [x] Define a protocol fee model that can layer on top of venue partner fees with a hard total-fee cap +- [x] Document the Cardano custody model for multisig governance plus automated execution +- [x] Integrate `DexHunter` partner flow for automated swaps +- [ ] Keep `Minswap` as fallback execution path +- [ ] Wire real Cardano wallet/provider dependencies into the DexHunter hot-wallet execution path +- [ ] Surface venue fee + protocol fee breakdown in the operator UI and audit logs +- [ ] Add a target-reference-asset policy mode, e.g. keep ADA exposure equivalent to `XAU/USD` ounces via Pyth feeds +- [x] Encode the bounded helper logic and emergency-withdraw rule in the `Aiken` `PolicyVault` scaffold +- [ ] Add real wallet adapters for Cardano (`CIP-30`) and SVM (`Wallet Standard`) instead of dashboard-side planning states +- [ ] Add a real frontend create-vault transaction flow on top of the preprod bootstrap builder +- [x] Move the Pyth/Cardano live collector source of truth into `apps/blockchain/cardano/offchain` +- [x] Move the Aiken scaffold source of truth into `apps/blockchain/cardano/contracts/aiken` +- [x] Encode the hot-bucket notional cap helpers in the `Aiken` `PolicyVault` scaffold +- [x] Rebuild the operator UI with a Squads-inspired treasury shell and dark dashboard layout +- [x] Add deterministic demo frames and replay controls for the breach -> de-risk -> exit -> recovery scenario +- [x] Refresh the local preview flow so the UI can be shown as a working product demo +- [ ] Finish the remaining `Aiken` validator work for `AuthorizeExecution`, `CompleteExecution`, `UpdatePolicy`, `Resume`, and recreated-output continuity +- [x] Add Vercel deployment scaffolding for `apps/ui` +- [x] Integrate real Pyth signed updates and preprod witness flow +- [x] Add deploy-prep scripts for Aiken contract doctor, blueprint build, and script address derivation +- [ ] Resolve the Pyth State UTxO automatically from a live Cardano provider instead of `.env` +- [ ] Integrate a live Cardano swap venue in the keeper execution path +- [ ] Replace SQLite demo persistence with deployable storage +- [ ] Connect the Vercel UI to a deployable backend/API environment instead of demo-local data +- [ ] Add CI for tests and typecheck +- [ ] Capture final demo screenshots / recording for the hackathon pitch +- [x] Prepare the `pyth-examples` submission tree under `/lazer/cardano/my-project/` +- [x] Adapt the submission README to the hackathon template from `pyth-examples` +- [x] Open the submission PR from the fork back to `pyth-network/pyth-examples` +- [ ] Fill the final team/contact fields in the `pyth-examples` README and PR body before final submission +- [ ] Review and address feedback on the `pyth-examples` draft PR diff --git a/lazer/cardano/guards/source/README.md b/lazer/cardano/guards/source/README.md new file mode 100644 index 00000000..a6c16b5c --- /dev/null +++ b/lazer/cardano/guards/source/README.md @@ -0,0 +1,251 @@ +

+ Guards +

+ +

Oracle-aware treasury policy enforcement for multichain DAOs.

+ +

+ guards.one is a treasury control plane that ingests real-time oracle data, evaluates risk against configurable policy rules, and executes protective swaps automatically, auditably, and across chains. +

+ +--- + +## The Problem + +DAOs and on-chain treasuries usually define treasury mandates in percentages, but the real failure mode is breaching an absolute fiat-denominated floor. When markets move fast, manual multisig coordination is too slow to protect value. + +Most treasury tooling either tracks prices passively or requires human intervention at every step. Neither approach is enough when the goal is to defend a hard protection floor under volatile conditions. + +## How Guards Solves It + +Guards turns treasury risk rules into executable actions: + +1. Ingest oracle snapshots from Pyth +2. Evaluate treasury health using drawdown, liquid value, confidence, and freshness +3. Authorize a bounded execution intent with oracle evidence +4. Execute an approved route from a capped hot wallet +5. Anchor an audit trail with intent, result, and oracle context + +## Implemented MVP Scope + +The current repository already includes: +- a shared core policy engine with liquid-value based triggers using `price`, `emaPrice`, `confidence`, freshness, and fiat floors +- a Cardano control-plane simulator plus an `Aiken` scaffold for the on-chain port +- backend services for collector, keeper, risk engine, audit logging, and end-to-end simulation +- a Squads-inspired operator UI shell for the treasury demo +- dashboard-side runtime controls for `mock replay` vs `preprod snapshot` +- mock wallet-session scaffolding for Cardano and SVM demo flows +- a 7-day / 15-minute historical replay that runs treasury strategies and shows simulated executions +- `SVM` and `EVM` scaffolding so the business logic remains multichain-native from day one + +## Risk Ladder + +| Stage | Trigger | Action | +|-------|---------|--------| +| **Normal** | Drawdown < watch threshold | Hold allocation, monitor feeds | +| **Watch** | Drawdown > `watchDrawdownBps` | Increase monitoring, prepare routes | +| **Partial De-Risk** | Drawdown > `partialDrawdownBps` | Swap part of the risk asset into the approved stable | +| **Full Stable Exit** | Drawdown > `fullExitDrawdownBps` or floor breached | Exit the remaining risk bucket into stable | +| **Frozen** | Oracle stale or confidence too wide | Block execution until data quality recovers | +| **Auto Re-Entry** | Recovery > `reentryDrawdownBps` + cooldown elapsed | Restore risk allocation gradually | + +The engine measures both percentage drawdown and absolute protected value: + +```text +liquid_value = amount × pyth_price × (1 − haircut_bps / 10000) +``` + +That means the system reacts not only to “asset dropped X%”, but also to “the treasury fell below the fiat floor we promised to defend”. + +## Architecture + +```text +packages/core + PolicyConfig · OracleSnapshot · RiskStage · ExecutionIntent · ExecutionResult + | + +-- packages/cardano PolicyVault simulator + DexHunter live adapter + +-- packages/svm scaffold connector + +-- packages/evm scaffold connector + | +apps/blockchain/cardano/contracts + Aiken scaffold for PolicyVault + hot-bucket rules + | +apps/backend + collector · risk-engine · keeper · storage · preview-server + | +apps/blockchain/cardano/offchain + live Pyth signed-update collector · witness builder · Cardano off-chain wiring + | +apps/ui + operator dashboard · replay demo · treasury shell +``` + +## Why Pyth Is Required + +Guards treats Pyth as a core system dependency, not a cosmetic data source. + +| Signal | How Guards Uses It | +|--------|-------------------| +| `price` | Spot valuation for liquid value calculations | +| `emaPrice` | Baseline for drawdown measurement | +| `confidence` | Confidence widening can freeze execution | +| `freshness` | Stale data blocks execution | +| snapshot IDs | Carried into the audit trail for verification | + +## Execution Model + +### Custody + +Guards uses a split custody model: +- governance treasury stays under multisig control +- execution bucket holds a bounded balance under a pre-approved policy +- automated swaps never spend directly from the governance multisig + +### Venue Strategy + +| Priority | Venue | Purpose | +|----------|-------|---------| +| Primary | DexHunter | Aggregated routing plus partner fee infrastructure | +| Fallback | Minswap Aggregator | Fallback execution path | + +Revenue is modeled in two layers: venue partner fee and protocol fee, both governance-capped and accounted separately from slippage. + +## How Guards Makes Money + +Guards makes money primarily by taking explicit, parameterized fees on the automated swaps it executes on behalf of the treasury, within the bounds already approved by governance. + +That revenue model has two layers, with different accounting treatment: + +1. `Venue / partner fee` + Where the execution venue supports partner or integrator monetization, Guards can participate in that fee flow. + - If the venue exposes that fee directly in the swap path, it is deducted from the swap output and recorded as `venue / partner fee` in the execution record. + - If the venue instead pays a partner rebate out of its own economics, that rebate is paid to a Guards-controlled address outside the treasury swap proceeds and is treated as Guards revenue in off-chain reporting rather than treasury-owned output. + +2. `Guards protocol fee` + Guards charges an explicit protocol fee deducted from the swap output. It is never added as a separate extra charge on top of the treasury's intended sell amount. + The protocol fee is surfaced as `protocol fee` in the execution record and capped by treasury policy. + +In the actual product flow: +- the treasury policy authorizes only approved routes and volumes +- Guards executes a protective swap when the policy triggers +- route selection enforces an allowed total fee envelope, while treasury policy separately caps the Guards protocol fee layer +- the execution record separates gross swap output, venue / partner fee when present on-chain, protocol fee, and net received amount +- the treasury keeps the protected output net of only the explicitly authorized on-chain execution fees +- Guards retains the explicit protocol fee as direct platform revenue +- where the venue supports partner or integrator fee sharing as a rebate, that rebate is recorded as venue-side partner revenue for Guards and is not treated as treasury-owned swap proceeds + +This is an intentional design choice. Guards does not rely on hidden spread, opaque slippage, or discretionary execution. The monetization model is explicit, capped, auditable, and directly tied to successful automated treasury protection. + +## Demo Flow + +The current simulation and UI replay cover: +- `watch` +- `partial_derisk` +- `full_exit` +- `auto re-entry` + +The frontend exposes that run as a deterministic operator demo with: +- a treasury workspace shell +- account tables for the risk bucket and stable reserve +- a replayable timeline for breach -> de-risk -> exit -> recovery +- charted backend series +- audit cards rendered from backend events + +## Team Collaboration Surface + +Chain-facing implementation work should start in [apps/blockchain](./apps/blockchain/README.md). + +That surface gives the team one obvious place to iterate on: +- Cardano contracts and off-chain execution wiring +- SVM connector work +- EVM connector work + +Reusable adapters and shared business logic still live in `packages/*`, but `apps/blockchain` is the collaboration anchor for new on-chain and connector work. + +## Quick Start + +```bash +pnpm install +pnpm typecheck +pnpm test +pnpm simulate +pnpm pyth:fetch-live +pnpm cardano:contract:doctor +pnpm preview +``` + +- Next.js preview (`pnpm preview`): `http://localhost:3000` +- Legacy static preview (`pnpm preview:legacy`): `http://localhost:4310` +- Runtime baseline: `Node >= 24.0.0` + +## Vercel + +The frontend can be deployed on `Vercel` from `apps/ui`. + +- Vercel Root Directory: `apps/ui` +- Node.js: `24.x` +- Config file: [apps/ui/vercel.json](./apps/ui/vercel.json) +- Deploy notes: [docs/vercel-deploy.md](./docs/vercel-deploy.md) + +## Environment + +Copy `.env.example` to `.env` and fill the required values: + +```bash +cp .env.example .env +``` + +Key variables: + +| Variable | Purpose | +|----------|---------| +| `PYTH_API_KEY` | Pyth oracle API access | +| `PYTH_PREPROD_POLICY_ID` | Pyth Cardano preprod policy binding | +| `PYTH_PRIMARY_SYMBOL_QUERY` | Symbol lookup used to resolve the live Pyth Lazer feed id | +| `CARDANO_BLOCKFROST_PROJECT_ID` | Cardano preprod provider | +| `DEXHUNTER_PARTNER_ID` | DexHunter partner fee capture | + +See [.env.example](./.env.example) for the full list. + +If you only need the Cardano simulator/types, import `@anaconda/blockchain/cardano`. +If you need the live Pyth/off-chain path, import `@anaconda/blockchain/cardano/offchain`. + +For contract deploy-prep: + +```bash +pnpm cardano:contract:doctor +pnpm cardano:contract:build +pnpm cardano:contract:address +``` + +The current `PolicyVault` validator is still fail-closed for `AuthorizeExecution` and `CompleteExecution` until output continuity, chain-derived time, and real on-chain Pyth verification are wired into the spend path. + +## Multichain Status + +| Chain | Status | Oracle | Execution | +|-------|--------|--------|-----------| +| **Cardano** | MVP in progress | Pyth | DexHunter / Minswap path in progress | +| **Solana (SVM)** | Scaffold only | Pyth-native target | Connector scaffold | +| **Ethereum (EVM)** | Scaffold only | Pyth EVM target | Connector scaffold | + +The core policy engine is shared across all chains. Execution remains local to each connected treasury. + +## Documentation + +| Document | Description | +|----------|-------------| +| [Functional Spec](./docs/functional-v4.md) | Product specification and risk model | +| [Roadmap](./docs/roadmap.md) | Delivery plan | +| [Cardano Swap Venue](./docs/cardano-swap-venue-decision.md) | Venue selection rationale | +| [Protocol Fee Model](./docs/protocol-fee-model.md) | Revenue and fee architecture | +| [Custody Model](./docs/cardano-custody-model.md) | Split-custody design | +| [DexHunter Adapter](./docs/dexhunter-live-adapter.md) | Live swap integration | +| [Pyth Live Collector](./docs/pyth-live-collector.md) | Signed-update fetch and Cardano witness wiring | +| [Vercel Deploy](./docs/vercel-deploy.md) | Frontend deployment setup for `apps/ui` | +| [Frontend Spec](./docs/landing-frontend-spec.md) | UI and UX direction | +| [Blockchain App Surface](./apps/blockchain/README.md) | Team-facing contracts and connector workspace | +| [Execution Tracker](./NEXT_STEPS.md) | Current engineering backlog | + +## License + +All rights reserved. Contact the team for licensing inquiries. diff --git a/lazer/cardano/guards/source/apps/backend/package.json b/lazer/cardano/guards/source/apps/backend/package.json new file mode 100644 index 00000000..ce3b7a4a --- /dev/null +++ b/lazer/cardano/guards/source/apps/backend/package.json @@ -0,0 +1,11 @@ +{ + "name": "@anaconda/backend", + "version": "0.0.0", + "private": true, + "type": "module", + "dependencies": { + "@anaconda/cardano": "workspace:*", + "@anaconda/core": "workspace:*", + "dotenv": "^17.2.3" + } +} diff --git a/lazer/cardano/guards/source/apps/backend/src/collector.ts b/lazer/cardano/guards/source/apps/backend/src/collector.ts new file mode 100644 index 00000000..adec527a --- /dev/null +++ b/lazer/cardano/guards/source/apps/backend/src/collector.ts @@ -0,0 +1 @@ +export * from "../../blockchain/cardano/offchain/collector.js"; diff --git a/lazer/cardano/guards/source/apps/backend/src/dashboard.ts b/lazer/cardano/guards/source/apps/backend/src/dashboard.ts new file mode 100644 index 00000000..4cbfadff --- /dev/null +++ b/lazer/cardano/guards/source/apps/backend/src/dashboard.ts @@ -0,0 +1,399 @@ +import { + baseCapabilities, + computePositionLiquidValue, + evaluateRiskLadder, + type OracleSnapshot, + type PolicyConfig, + type RouteSpec, + type TreasuryState, +} from "@anaconda/core"; +import type { TickResult } from "./keeper.js"; +import type { AuditEvent } from "./storage.js"; + +export interface DashboardSeriesPoint { + label: string; + stage: TreasuryState["stage"]; + valueFiat: number; +} + +export interface DashboardDemoFrame { + label: string; + title: string; + copy: string; + stage: TreasuryState["stage"]; + valueFiat: number; + stableRatio: number; + reason: string; +} + +export interface DashboardPayload { + generatedAtUs: number; + source: string; + workspace: { + name: string; + label: string; + chain: string; + stage: string; + threshold: string; + members: string; + hotWallet: string; + governanceWallet: string; + totalBalance: string; + primaryAsset: string; + stableAsset: string; + vaultId: string; + }; + topbarChips: Array<{ + label: string; + value: string; + tone: string; + }>; + heroMetrics: Array<{ + label: string; + value: string; + copy: string; + chip?: string; + }>; + dashboardCards: Array<{ + label: string; + value: string; + copy: string; + chip: string; + }>; + chainCards: Array<{ + chain: string; + title: string; + copy: string; + chip: string; + }>; + riskLadder: Array<{ + stage: string; + title: string; + copy: string; + tone?: string; + }>; + executionTimeline: Array<{ + title: string; + copy: string; + status: string; + }>; + auditTrail: Array<{ + title: string; + copy: string; + stamp: string; + }>; + accounts: Array<{ + label: string; + address: string; + balance: string; + fiatValue: string; + weight: string; + role: string; + bucket: string; + }>; + portfolioSeries: Array<{ + label: string; + stage: string; + value: number; + displayValue: string; + }>; + demoFrames: Array<{ + label: string; + title: string; + copy: string; + stage: string; + balance: string; + stableRatio: string; + reason: string; + }>; +} + +function formatUsd(value: number): string { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + maximumFractionDigits: value >= 1000 ? 0 : 2, + }).format(value); +} + +function formatPercent(value: number): string { + return `${(value * 100).toFixed(1)}%`; +} + +function formatAgeUs(nowUs: number, thenUs: number): string { + const seconds = Math.max(0, Math.round((nowUs - thenUs) / 1_000_000)); + return `${seconds}s`; +} + +function formatAmount(value: number): string { + return new Intl.NumberFormat("en-US", { + maximumFractionDigits: value >= 1000 ? 0 : 2, + }).format(value); +} + +function shortAddress(value: string): string { + if (value.length <= 14) { + return value; + } + + return `${value.slice(0, 8)}...${value.slice(-6)}`; +} + +function stageLabel(stage: TreasuryState["stage"]): string { + switch (stage) { + case "normal": + return "Normal"; + case "watch": + return "Watch"; + case "partial_derisk": + return "Partial De-Risk"; + case "full_exit": + return "Full Stable Exit"; + case "frozen": + return "Frozen"; + } +} + +function stageChip(stage: TreasuryState["stage"]): string { + switch (stage) { + case "normal": + return "ok"; + case "watch": + return "warn"; + case "partial_derisk": + return "warn"; + case "full_exit": + return "danger"; + case "frozen": + return "danger"; + } +} + +function formatAuditCopy(event: AuditEvent): string { + switch (event.category) { + case "snapshot": { + const assetId = typeof event.payload.assetId === "string" ? event.payload.assetId : "asset"; + const snapshotId = + typeof event.payload.snapshotId === "string" ? event.payload.snapshotId : "unknown snapshot"; + return `Observed ${assetId.toUpperCase()} from ${snapshotId}.`; + } + case "intent": { + const kind = typeof event.payload.kind === "string" ? event.payload.kind.replaceAll("_", " ") : "intent"; + const stage = typeof event.payload.stage === "string" ? event.payload.stage.replaceAll("_", " ") : "n/a"; + return `Authorized ${kind} at stage ${stage}.`; + } + case "execution": { + const sold = typeof event.payload.soldAmount === "number" ? formatAmount(event.payload.soldAmount) : "n/a"; + const bought = typeof event.payload.boughtAmount === "number" ? formatAmount(event.payload.boughtAmount) : "n/a"; + const stage = typeof event.payload.stage === "string" ? event.payload.stage.replaceAll("_", " ") : "n/a"; + return `Settled ${stage}. Sold ${sold} and bought ${bought}.`; + } + case "rejection": { + const code = typeof event.payload.code === "string" ? event.payload.code : "unknown"; + const keeperId = typeof event.payload.keeperId === "string" ? event.payload.keeperId : "keeper"; + return `Rejected with ${code} for ${keeperId}.`; + } + } +} + +export function buildDashboardPayload(input: { + treasury: TreasuryState; + policy: PolicyConfig; + routes: RouteSpec[]; + snapshots: Record; + operations: TickResult[]; + events: AuditEvent[]; + nowUs: number; + portfolioSeries: DashboardSeriesPoint[]; + demoFrames: DashboardDemoFrame[]; +}): DashboardPayload { + const assessment = evaluateRiskLadder( + input.treasury, + input.policy, + input.snapshots, + input.routes, + input.nowUs, + ); + const latestSnapshot = input.snapshots[input.policy.primaryAssetId]; + const route = input.routes.find((candidate) => + input.policy.approvedRouteIds.includes(candidate.routeId), + ); + const topReason = assessment.reasons[0]?.message ?? "Policy is currently inside the safe band."; + const liveChains = Object.values(baseCapabilities); + const riskPosition = input.treasury.positions.find((position) => position.role === "risk"); + const stablePosition = input.treasury.positions.find((position) => position.role === "stable"); + const accounts = input.treasury.positions.map((position) => { + const snapshot = input.snapshots[position.assetId]; + const liquidValue = computePositionLiquidValue(position, snapshot, input.policy); + const weight = + assessment.metrics.totalLiquidValueFiat === 0 + ? 0 + : liquidValue / assessment.metrics.totalLiquidValueFiat; + const wallet = + position.bucket === "hot" ? input.treasury.executionHotWallet : input.treasury.governanceWallet; + + return { + label: `${position.symbol} ${position.role === "risk" ? "risk bucket" : "stable reserve"}`, + address: shortAddress(wallet), + balance: `${formatAmount(position.amount)} ${position.symbol}`, + fiatValue: formatUsd(liquidValue), + weight: formatPercent(weight), + role: position.role === "risk" ? "Risk asset" : "Stable reserve", + bucket: position.bucket === "hot" ? "Execution hot" : "Governance cold", + }; + }); + + return { + generatedAtUs: input.nowUs, + source: "backend-demo", + workspace: { + name: input.treasury.name, + label: "guards.one live desk", + chain: "Cardano preprod", + stage: stageLabel(input.treasury.stage), + threshold: `${input.treasury.governanceSigners.length} governance signers`, + members: `${input.treasury.riskManagers.length} risk manager · ${input.treasury.keepers.length} keepers`, + hotWallet: shortAddress(input.treasury.executionHotWallet), + governanceWallet: shortAddress(input.treasury.governanceWallet), + totalBalance: formatUsd(assessment.metrics.totalLiquidValueFiat), + primaryAsset: riskPosition?.symbol ?? input.policy.primaryAssetId.toUpperCase(), + stableAsset: stablePosition?.symbol ?? input.policy.stableAssetId.toUpperCase(), + vaultId: input.treasury.vaultId, + }, + topbarChips: [ + { + label: "Network status", + value: "Cardano preprod live", + tone: "live", + }, + { + label: "Approved route", + value: route ? `${route.venue} · ${route.toAssetId.toUpperCase()}` : "No route", + tone: route?.live ? "neutral" : "warn", + }, + { + label: "Execution wallet", + value: shortAddress(input.treasury.executionHotWallet), + tone: "neutral", + }, + ], + heroMetrics: [ + { + label: "Current stage", + value: stageLabel(input.treasury.stage), + copy: "Final policy state after the simulated breach and recovery run.", + chip: stageChip(input.treasury.stage), + }, + { + label: "Treasury liquid value", + value: formatUsd(assessment.metrics.totalLiquidValueFiat), + copy: "Valued from the latest Pyth price and haircut-aware liquid value math.", + }, + { + label: "Stable protection", + value: formatPercent(assessment.metrics.stableRatio), + copy: "Current treasury share parked in the approved stable reserve.", + }, + { + label: "Oracle freshness", + value: latestSnapshot + ? formatAgeUs(input.nowUs, latestSnapshot.feedUpdateTimestampUs) + : "missing", + copy: "Age of the primary feed update relative to the latest policy evaluation.", + }, + ], + dashboardCards: [ + { + label: "Protected floor", + value: formatUsd(input.policy.portfolioFloorFiat), + copy: "Minimum fiat-equivalent value the policy tries to keep defended at all times.", + chip: "ok", + }, + { + label: "Emergency floor", + value: formatUsd(input.policy.emergencyPortfolioFloorFiat), + copy: "Crossing this floor escalates the vault into a full stable exit or freeze path.", + chip: "danger", + }, + { + label: "Primary reason", + value: topReason, + copy: "Top reason emitted by the risk engine for the current snapshot set.", + chip: assessment.reasons.length > 0 ? "warn" : "ok", + }, + { + label: "Execution policy", + value: route + ? `${route.chainId.toUpperCase()} · ${route.venue} · ${route.toAssetId.toUpperCase()}` + : "No approved route", + copy: "Only allowlisted routes can spend from the execution hot wallet.", + chip: route?.live ? "ok" : "warn", + }, + ], + chainCards: liveChains.map((capability) => ({ + chain: capability.chainId.toUpperCase(), + title: capability.live ? "Live execution surface" : "Scaffolded adapter", + copy: capability.notes.join(" "), + chip: capability.live ? "live" : "scaffold", + })), + riskLadder: [ + { + stage: "Normal", + title: "Operate with full permissions", + copy: "Fresh oracle, healthy confidence, and no forced action path active.", + }, + { + stage: "Watch", + title: "Increase monitoring", + copy: "The drawdown or fiat floor approaches the first trigger band.", + tone: "partial", + }, + { + stage: "Partial De-Risk", + title: "Sell only what restores the safe floor", + copy: "The keeper sells a bounded slice of the risky bucket into the approved stable route.", + tone: "partial", + }, + { + stage: "Full Stable Exit", + title: "Move the vault fully defensive", + copy: "A deeper breach exits the risk bucket and keeps the hot wallet on one stable rail.", + tone: "full", + }, + { + stage: "Auto Re-entry", + title: "Re-risk only after hysteresis clears", + copy: "Recovery must clear a separate band and cooldown before exposure comes back.", + tone: "reentry", + }, + ], + executionTimeline: input.operations.map((operation, index) => ({ + title: `Step ${index + 1} · ${stageLabel(operation.stage)}`, + copy: operation.rejected + ? `Execution rejected with ${operation.rejected}.` + : `Intent ${operation.intentId ?? "n/a"} anchored and settled in tx ${operation.txHash ?? "n/a"}.`, + status: operation.rejected ? "rejected" : "executed", + })), + auditTrail: input.events.slice(-6).map((event) => ({ + title: `${event.category.toUpperCase()} · ${event.eventId}`, + copy: formatAuditCopy(event), + stamp: formatAgeUs(input.nowUs, event.createdAtUs), + })), + accounts, + portfolioSeries: input.portfolioSeries.map((point) => ({ + label: point.label, + stage: point.stage, + value: point.valueFiat, + displayValue: formatUsd(point.valueFiat), + })), + demoFrames: input.demoFrames.map((frame) => ({ + label: frame.label, + title: frame.title, + copy: frame.copy, + stage: frame.stage, + balance: formatUsd(frame.valueFiat), + stableRatio: formatPercent(frame.stableRatio), + reason: frame.reason, + })), + }; +} diff --git a/lazer/cardano/guards/source/apps/backend/src/demo-state.ts b/lazer/cardano/guards/source/apps/backend/src/demo-state.ts new file mode 100644 index 00000000..811afebd --- /dev/null +++ b/lazer/cardano/guards/source/apps/backend/src/demo-state.ts @@ -0,0 +1,290 @@ +import { + evaluateRiskLadder, + type TreasuryState, +} from "@anaconda/core"; +import type { TickResult } from "./keeper.js"; +import { + buildDashboardPayload, + type DashboardDemoFrame, + type DashboardPayload, + type DashboardSeriesPoint, +} from "./dashboard.js"; +import { PythCollector } from "./collector.js"; +import { buildDemoScenario, sampleWitness } from "./fixtures.js"; +import { CardanoKeeperService } from "./keeper.js"; +import { AuditStore } from "./storage.js"; +import { buildSnapshots } from "@anaconda/core"; + +export interface DemoState { + payload: DashboardPayload; + operations: { + partial: TickResult; + fullExit: TickResult; + reentry: TickResult; + }; + counts: ReturnType; + events: ReturnType; +} + +function publishAll( + collector: PythCollector, + snapshots: Record[string]>, +) { + for (const snapshot of Object.values(snapshots)) { + collector.publish(snapshot); + } +} + +function reasonForFrame(result: ReturnType, fallback: string): string { + return result.reasons[0]?.message ?? fallback; +} + +function point(label: string, stage: TreasuryState["stage"], valueFiat: number): DashboardSeriesPoint { + return { + label, + stage, + valueFiat, + }; +} + +function frame(input: { + label: string; + title: string; + copy: string; + stage: TreasuryState["stage"]; + valueFiat: number; + stableRatio: number; + reason: string; +}): DashboardDemoFrame { + return input; +} + +export function createDemoState(): DemoState { + const auditStore = new AuditStore(); + const collector = new PythCollector(auditStore); + const keeper = new CardanoKeeperService(sampleWitness.pythPolicyId, auditStore); + const scenario = buildDemoScenario(); + + publishAll( + collector, + buildSnapshots({ + ada: { + snapshotId: "snapshot-ada-watch", + price: 75, + emaPrice: 80, + feedUpdateTimestampUs: 180_000_000, + observedAtUs: 180_000_000, + }, + usdm: { + snapshotId: "snapshot-usdm-watch", + feedUpdateTimestampUs: 180_000_000, + observedAtUs: 180_000_000, + }, + }), + ); + let treasury = scenario.treasury; + + const baselineAssessment = evaluateRiskLadder( + treasury, + scenario.policy, + collector.current(), + scenario.routes, + 180_000_000, + ); + + publishAll( + collector, + buildSnapshots({ + ada: { + snapshotId: "snapshot-ada-partial", + price: 72, + emaPrice: 80, + feedUpdateTimestampUs: 200_000_000, + observedAtUs: 200_000_000, + }, + usdm: { + snapshotId: "snapshot-usdm-partial", + feedUpdateTimestampUs: 200_000_000, + observedAtUs: 200_000_000, + }, + }), + ); + + const partial = keeper.tick({ + treasury, + policy: scenario.policy, + routes: scenario.routes, + snapshots: collector.current(), + nowUs: 200_000_000, + keeperId: "keeper-1", + witness: sampleWitness, + }); + treasury = partial.treasury; + + const partialAssessment = evaluateRiskLadder( + treasury, + scenario.policy, + collector.current(), + scenario.routes, + 200_000_000, + ); + + publishAll( + collector, + buildSnapshots({ + ada: { + snapshotId: "snapshot-ada-crash", + price: 39, + emaPrice: 80, + feedUpdateTimestampUs: 240_000_000, + observedAtUs: 240_000_000, + }, + usdm: { + snapshotId: "snapshot-usdm-2", + feedUpdateTimestampUs: 240_000_000, + observedAtUs: 240_000_000, + }, + }), + ); + + const fullExit = keeper.tick({ + treasury, + policy: scenario.policy, + routes: scenario.routes, + snapshots: collector.current(), + nowUs: 260_000_000, + keeperId: "keeper-1", + witness: sampleWitness, + }); + treasury = fullExit.treasury; + + const fullExitAssessment = evaluateRiskLadder( + treasury, + scenario.policy, + collector.current(), + scenario.routes, + 260_000_000, + ); + + publishAll( + collector, + buildSnapshots({ + ada: { + snapshotId: "snapshot-ada-recovery", + price: 79, + emaPrice: 80, + confidence: 0.2, + feedUpdateTimestampUs: 340_000_000, + observedAtUs: 340_000_000, + }, + usdm: { + snapshotId: "snapshot-usdm-3", + feedUpdateTimestampUs: 340_000_000, + observedAtUs: 340_000_000, + }, + }), + ); + + const reentry = keeper.tick({ + treasury, + policy: scenario.policy, + routes: scenario.routes, + snapshots: collector.current(), + nowUs: 360_000_000, + keeperId: "keeper-1", + witness: sampleWitness, + }); + + const reentryAssessment = evaluateRiskLadder( + reentry.treasury, + scenario.policy, + collector.current(), + scenario.routes, + 360_000_000, + ); + + const events = auditStore.listEvents(); + const portfolioSeries: DashboardSeriesPoint[] = [ + point("Watch", "watch", baselineAssessment.metrics.totalLiquidValueFiat), + point("Partial", partial.treasury.stage, partialAssessment.metrics.totalLiquidValueFiat), + point("Exit", fullExit.treasury.stage, fullExitAssessment.metrics.totalLiquidValueFiat), + point("Recovery", reentry.treasury.stage, reentryAssessment.metrics.totalLiquidValueFiat), + ]; + const demoFrames: DashboardDemoFrame[] = [ + frame({ + label: "01", + title: "Watchlist breach detected", + copy: + "ADA slips under its EMA while Pyth freshness and confidence remain healthy enough to authorize a policy action.", + stage: "watch", + valueFiat: baselineAssessment.metrics.totalLiquidValueFiat, + stableRatio: baselineAssessment.metrics.stableRatio, + reason: reasonForFrame( + baselineAssessment, + "Drawdown approaches the first policy band.", + ), + }), + frame({ + label: "02", + title: "Partial de-risk executes", + copy: + "The keeper emits an intent, swaps only the bounded amount needed, and restores the defended stable floor.", + stage: partial.treasury.stage, + valueFiat: partialAssessment.metrics.totalLiquidValueFiat, + stableRatio: partialAssessment.metrics.stableRatio, + reason: reasonForFrame( + partialAssessment, + "Partial stable target restored.", + ), + }), + frame({ + label: "03", + title: "Full stable exit after the second leg down", + copy: + "A deeper price break and thinner asset cushion push the vault into a full defensive configuration on the approved stable route.", + stage: fullExit.treasury.stage, + valueFiat: fullExitAssessment.metrics.totalLiquidValueFiat, + stableRatio: fullExitAssessment.metrics.stableRatio, + reason: reasonForFrame( + fullExitAssessment, + "Emergency floor forced the full stable path.", + ), + }), + frame({ + label: "04", + title: "Auto re-entry restores exposure", + copy: + "Recovery clears the hysteresis band, cooldown expires, and the treasury re-enters risk according to the configured target ratio.", + stage: reentry.treasury.stage, + valueFiat: reentryAssessment.metrics.totalLiquidValueFiat, + stableRatio: reentryAssessment.metrics.stableRatio, + reason: reasonForFrame( + reentryAssessment, + "Recovery cleared the re-entry guardrails.", + ), + }), + ]; + + const payload = buildDashboardPayload({ + treasury: reentry.treasury, + policy: scenario.policy, + routes: scenario.routes, + snapshots: collector.current(), + operations: [partial, fullExit, reentry], + events, + nowUs: 360_000_000, + portfolioSeries, + demoFrames, + }); + + return { + payload, + operations: { + partial, + fullExit, + reentry, + }, + counts: auditStore.counts(), + events, + }; +} diff --git a/lazer/cardano/guards/source/apps/backend/src/dexhunter-keeper.ts b/lazer/cardano/guards/source/apps/backend/src/dexhunter-keeper.ts new file mode 100644 index 00000000..398e514b --- /dev/null +++ b/lazer/cardano/guards/source/apps/backend/src/dexhunter-keeper.ts @@ -0,0 +1,136 @@ +import { randomUUID } from "node:crypto"; +import type { PolicyConfig, RouteSpec, TreasuryState } from "@anaconda/core"; +import { + CardanoExecutionError, + PolicyVaultSimulator, + type CardanoPythWitness, +} from "@anaconda/cardano"; +import { AuditStore } from "./storage.js"; +import { + DexHunterLiveAdapter, + DexHunterLiveError, + type CardanoHotWallet, +} from "./dexhunter-live.js"; + +export interface LiveTickInput { + treasury: TreasuryState; + policy: PolicyConfig; + routes: RouteSpec[]; + snapshots: ReturnType; + nowUs: number; + keeperId: string; + witness: CardanoPythWitness; + wallet: CardanoHotWallet; + assetTokenIds: Record; + blacklistedDexes?: string[]; +} + +export interface LiveTickResult { + treasury: TreasuryState; + intentId?: string; + txHash?: string; + stage: TreasuryState["stage"]; + rejected?: string; +} + +export class CardanoDexHunterKeeperService { + private readonly simulator: PolicyVaultSimulator; + private readonly adapter: DexHunterLiveAdapter; + + constructor( + pythPolicyId: string, + private readonly auditStore: AuditStore, + adapter = new DexHunterLiveAdapter(), + ) { + this.simulator = new PolicyVaultSimulator(pythPolicyId); + this.adapter = adapter; + } + + async tick(input: LiveTickInput): Promise { + try { + const authorization = this.simulator.authorizeExecution({ + treasury: input.treasury, + policy: input.policy, + snapshots: input.snapshots, + routes: input.routes, + nowUs: input.nowUs, + keeperId: input.keeperId, + witness: input.witness, + }); + + this.auditStore.recordIntent(authorization.intent); + this.auditStore.recordEvent({ + eventId: `intent:${authorization.intent.intentId}`, + category: "intent", + payload: { + stage: authorization.intent.stage, + kind: authorization.intent.kind, + reasonHash: authorization.intent.reasonHash, + }, + createdAtUs: authorization.intent.createdAtUs, + }); + + const execution = await this.adapter.executeIntent({ + intent: authorization.intent, + routes: input.routes, + wallet: input.wallet, + assetTokenIds: input.assetTokenIds, + nowUs: input.nowUs + 1_000, + ...(input.blacklistedDexes + ? { blacklistedDexes: input.blacklistedDexes } + : {}), + }); + + const completion = this.simulator.completeExecution({ + treasury: authorization.treasury, + policy: input.policy, + intent: authorization.intent, + result: execution.result, + }); + + this.auditStore.recordExecution(execution.result); + this.auditStore.recordEvent({ + eventId: `execution:${execution.result.txHash}`, + category: "execution", + payload: { + intentId: execution.result.intentId, + txHash: execution.result.txHash, + soldAmount: execution.result.soldAmount, + boughtAmount: execution.result.boughtAmount, + stage: completion.treasury.stage, + venueFeeAmount: execution.revenueBreakdown.venueFeeAmount, + protocolFeeAmount: execution.revenueBreakdown.protocolFeeAmount, + totalFeeBps: execution.revenueBreakdown.totalFeeBps, + }, + createdAtUs: execution.result.executedAtUs, + }); + + return { + treasury: completion.treasury, + intentId: authorization.intent.intentId, + txHash: execution.result.txHash, + stage: completion.treasury.stage, + }; + } catch (error) { + const code = + error instanceof CardanoExecutionError || error instanceof DexHunterLiveError + ? error.code + : "UNKNOWN_ERROR"; + this.auditStore.recordEvent({ + eventId: `rejection:${randomUUID()}`, + category: "rejection", + payload: { + code, + keeperId: input.keeperId, + }, + createdAtUs: input.nowUs, + }); + + return { + treasury: input.treasury, + stage: input.treasury.stage, + rejected: code, + }; + } + } +} diff --git a/lazer/cardano/guards/source/apps/backend/src/dexhunter-live.ts b/lazer/cardano/guards/source/apps/backend/src/dexhunter-live.ts new file mode 100644 index 00000000..3b22210b --- /dev/null +++ b/lazer/cardano/guards/source/apps/backend/src/dexhunter-live.ts @@ -0,0 +1,300 @@ +import type { ExecutionIntent, ExecutionResult, RouteSpec } from "@anaconda/core"; +import { runtimeEnv } from "./env.js"; +import { + applyRevenueFees, + buildProtocolFeePolicy, + getConfiguredVenueFeePercent, + type RevenueBreakdown, +} from "./protocol-fee.js"; +import { + getCardanoSwapVenueConfig, + type CardanoSwapVenueConfig, +} from "./swap-venue.js"; + +export interface CardanoHotWallet { + address: string; + signTx(cbor: string, partialSign?: boolean): Promise; + submitTx(cbor: string): Promise; +} + +export interface DexHunterEstimatePayload { + token_in: string; + token_out: string; + amount_in: number; + slippage: number; + blacklisted_dexes: string[]; +} + +export interface DexHunterEstimateResponse { + total_output: number; + total_output_without_slippage: number; + possible_routes: Array<{ + dex: string; + amount_in: number; + expected_output: number; + }>; +} + +export interface DexHunterBuildPayload extends DexHunterEstimatePayload { + buyer_address: string; +} + +export interface DexHunterBuildResponse { + cbor: string; + total_input: number; + total_output: number; + splits: Array<{ + dex: string; + amount_in: number; + expected_output: number; + }>; +} + +export interface DexHunterSignResponse { + cbor: string; +} + +export interface DexHunterIntentExecutionParams { + intent: ExecutionIntent; + routes: RouteSpec[]; + wallet: CardanoHotWallet; + assetTokenIds: Record; + nowUs: number; + blacklistedDexes?: string[]; +} + +export interface DexHunterExecutionArtifacts { + quote: DexHunterEstimateResponse; + build: DexHunterBuildResponse; + signedCbor: string; + revenueBreakdown: RevenueBreakdown; +} + +export interface DexHunterExecutionOutcome extends DexHunterExecutionArtifacts { + result: ExecutionResult; +} + +export interface DexHunterLiveAdapterConfig { + venueConfig: CardanoSwapVenueConfig; + maxTotalFeeBps: number; + protocolFeeMode: string; +} + +export class DexHunterLiveError extends Error { + code: string; + + constructor(code: string, message: string) { + super(message); + this.code = code; + } +} + +export type FetchLike = typeof fetch; + +export class DexHunterApiClient { + constructor( + private readonly baseUrl: string, + private readonly partnerId: string, + private readonly fetchImpl: FetchLike = fetch, + ) {} + + private get headers() { + if (!this.partnerId) { + throw new DexHunterLiveError( + "DEXHUNTER_PARTNER_ID_MISSING", + "DexHunter requires an X-Partner-Id header for API requests", + ); + } + + return { + "Content-Type": "application/json", + "X-Partner-Id": this.partnerId, + }; + } + + private async postJson(path: string, body: unknown): Promise { + const response = await this.fetchImpl(`${this.baseUrl}${path}`, { + method: "POST", + headers: this.headers, + body: JSON.stringify(body), + }); + + if (!response.ok) { + throw new DexHunterLiveError( + "DEXHUNTER_HTTP_ERROR", + `DexHunter request failed (${response.status}) for ${path}`, + ); + } + + return (await response.json()) as T; + } + + estimateSwap(payload: DexHunterEstimatePayload) { + return this.postJson("/swap/estimate", payload); + } + + buildSwap(payload: DexHunterBuildPayload) { + return this.postJson("/swap/build", payload); + } + + signSwap(txCbor: string, signatures: string) { + return this.postJson("/swap/sign", { + txCbor, + signatures, + }); + } +} + +function resolveTokenId(assetId: string, assetTokenIds: Record): string { + if (assetId === "ada") { + return ""; + } + + const tokenId = assetTokenIds[assetId]; + if (!tokenId) { + throw new DexHunterLiveError( + "TOKEN_ID_MISSING", + `Asset ${assetId} is missing a DexHunter token identifier`, + ); + } + + return tokenId; +} + +function resolveRoute(intent: ExecutionIntent, routes: RouteSpec[]): RouteSpec { + const route = routes.find((candidate) => candidate.routeId === intent.routeId); + if (!route) { + throw new DexHunterLiveError( + "ROUTE_NOT_FOUND", + `Route ${intent.routeId} is not available for DexHunter execution`, + ); + } + + return route; +} + +function computeAveragePrice(soldAmount: number, boughtAmount: number): number { + if (soldAmount <= 0 || boughtAmount <= 0) { + return 0; + } + + return Number((boughtAmount / soldAmount).toFixed(8)); +} + +export class DexHunterLiveAdapter { + private readonly client: DexHunterApiClient; + private readonly config: DexHunterLiveAdapterConfig; + + constructor( + private readonly fetchImpl: FetchLike = fetch, + config: Partial = {}, + ) { + const venueConfig = config.venueConfig ?? getCardanoSwapVenueConfig(); + this.config = { + venueConfig, + maxTotalFeeBps: config.maxTotalFeeBps ?? runtimeEnv.cardanoMaxTotalFeeBps, + protocolFeeMode: config.protocolFeeMode ?? runtimeEnv.cardanoProtocolFeeMode, + }; + this.client = new DexHunterApiClient( + venueConfig.dexHunter.baseUrl, + venueConfig.dexHunter.partnerId, + this.fetchImpl, + ); + } + + buildIntentPayload(params: DexHunterIntentExecutionParams): DexHunterBuildPayload { + const route = resolveRoute(params.intent, params.routes); + + return { + buyer_address: params.wallet.address, + token_in: resolveTokenId(params.intent.sourceAssetId, params.assetTokenIds), + token_out: resolveTokenId(params.intent.destinationAssetId, params.assetTokenIds), + amount_in: params.intent.maxSellAmount, + slippage: Number((route.maxSlippageBps / 100).toFixed(2)), + blacklisted_dexes: params.blacklistedDexes ?? [], + }; + } + + estimateRevenue(totalOutput: number): RevenueBreakdown { + return applyRevenueFees( + totalOutput, + buildProtocolFeePolicy({ + provider: "dexhunter", + venueFeePercent: getConfiguredVenueFeePercent(this.config.venueConfig), + protocolFeeBps: this.config.venueConfig.protocolFeeBps, + maxTotalFeeBps: this.config.maxTotalFeeBps, + protocolFeeMode: this.config.protocolFeeMode, + }), + ); + } + + async executeIntent(params: DexHunterIntentExecutionParams): Promise { + if (this.config.venueConfig.provider !== "dexhunter") { + throw new DexHunterLiveError( + "DEXHUNTER_NOT_PRIMARY_PROVIDER", + "Current runtime configuration is not set to DexHunter", + ); + } + if (params.nowUs < params.intent.createdAtUs) { + throw new DexHunterLiveError( + "INTENT_NOT_YET_VALID", + `Execution intent ${params.intent.intentId} is not yet valid`, + ); + } + if (params.nowUs > params.intent.expiryUs) { + throw new DexHunterLiveError( + "INTENT_EXPIRED", + `Execution intent ${params.intent.intentId} has expired`, + ); + } + + const payload = this.buildIntentPayload(params); + const quote = await this.client.estimateSwap({ + token_in: payload.token_in, + token_out: payload.token_out, + amount_in: payload.amount_in, + slippage: payload.slippage, + blacklisted_dexes: payload.blacklisted_dexes, + }); + + if (quote.total_output < params.intent.minBuyAmount) { + throw new DexHunterLiveError( + "QUOTE_BELOW_MIN_BUY", + `DexHunter quote ${quote.total_output} is below min buy ${params.intent.minBuyAmount}`, + ); + } + + const build = await this.client.buildSwap(payload); + if (build.total_output < params.intent.minBuyAmount) { + throw new DexHunterLiveError( + "BUILD_BELOW_MIN_BUY", + `DexHunter build output ${build.total_output} is below min buy ${params.intent.minBuyAmount}`, + ); + } + + const signatures = await params.wallet.signTx(build.cbor, true); + const signed = await this.client.signSwap(build.cbor, signatures); + const txHash = await params.wallet.submitTx(signed.cbor); + const revenueBreakdown = this.estimateRevenue(build.total_output); + + return { + quote, + build, + signedCbor: signed.cbor, + revenueBreakdown, + result: { + intentId: params.intent.intentId, + vaultId: params.intent.vaultId, + chainId: "cardano", + sourceAssetId: params.intent.sourceAssetId, + destinationAssetId: params.intent.destinationAssetId, + soldAmount: build.total_input, + boughtAmount: build.total_output, + averagePrice: computeAveragePrice(build.total_input, build.total_output), + txHash, + executedAtUs: params.nowUs, + routeId: params.intent.routeId, + }, + }; + } +} diff --git a/lazer/cardano/guards/source/apps/backend/src/env.ts b/lazer/cardano/guards/source/apps/backend/src/env.ts new file mode 100644 index 00000000..7eaa25d3 --- /dev/null +++ b/lazer/cardano/guards/source/apps/backend/src/env.ts @@ -0,0 +1 @@ +export { runtimeEnv } from "../../blockchain/cardano/offchain/env.js"; diff --git a/lazer/cardano/guards/source/apps/backend/src/export-ui-data.ts b/lazer/cardano/guards/source/apps/backend/src/export-ui-data.ts new file mode 100644 index 00000000..b66b3792 --- /dev/null +++ b/lazer/cardano/guards/source/apps/backend/src/export-ui-data.ts @@ -0,0 +1,16 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { createDemoState } from "./demo-state.js"; +import "./env.js"; + +const outputDir = path.resolve(process.cwd(), "apps/ui/data"); +const outputFile = path.join(outputDir, "demo-state.json"); + +await mkdir(outputDir, { recursive: true }); +await writeFile( + outputFile, + `${JSON.stringify(createDemoState().payload, null, 2)}\n`, + "utf8", +); + +console.log(`Wrote ${outputFile}`); diff --git a/lazer/cardano/guards/source/apps/backend/src/fixtures.ts b/lazer/cardano/guards/source/apps/backend/src/fixtures.ts new file mode 100644 index 00000000..3f904d35 --- /dev/null +++ b/lazer/cardano/guards/source/apps/backend/src/fixtures.ts @@ -0,0 +1,5 @@ +export { + buildDemoScenario, + buildPrimaryLiveFeedRequest, + sampleWitness, +} from "../../blockchain/cardano/offchain/fixtures.js"; diff --git a/lazer/cardano/guards/source/apps/backend/src/keeper.ts b/lazer/cardano/guards/source/apps/backend/src/keeper.ts new file mode 100644 index 00000000..7f741cf6 --- /dev/null +++ b/lazer/cardano/guards/source/apps/backend/src/keeper.ts @@ -0,0 +1,135 @@ +import { randomUUID } from "node:crypto"; +import { + type ExecutionResult, + type PolicyConfig, + type RouteSpec, + type TreasuryState, +} from "@anaconda/core"; +import { + CardanoExecutionError, + PolicyVaultSimulator, + createCardanoConnector, + type CardanoPythWitness, +} from "@anaconda/cardano"; +import { AuditStore } from "./storage.js"; + +export interface TickInput { + treasury: TreasuryState; + policy: PolicyConfig; + routes: RouteSpec[]; + snapshots: ReturnType; + nowUs: number; + keeperId: string; + witness: CardanoPythWitness; +} + +export interface TickResult { + treasury: TreasuryState; + intentId?: string; + txHash?: string; + stage: TreasuryState["stage"]; + rejected?: string; +} + +export class CardanoKeeperService { + private readonly simulator: PolicyVaultSimulator; + + constructor( + pythPolicyId: string, + private readonly auditStore: AuditStore, + ) { + this.simulator = new PolicyVaultSimulator(pythPolicyId); + } + + tick(input: TickInput): TickResult { + const connector = createCardanoConnector(input.routes); + + try { + const authorization = this.simulator.authorizeExecution({ + treasury: input.treasury, + policy: input.policy, + snapshots: input.snapshots, + routes: input.routes, + nowUs: input.nowUs, + keeperId: input.keeperId, + witness: input.witness, + }); + + this.auditStore.recordIntent(authorization.intent); + this.auditStore.recordEvent({ + eventId: `intent:${authorization.intent.intentId}`, + category: "intent", + payload: { + stage: authorization.intent.stage, + kind: authorization.intent.kind, + reasonHash: authorization.intent.reasonHash, + }, + createdAtUs: authorization.intent.createdAtUs, + }); + + const simulated = connector.simulateRoute( + authorization.intent, + authorization.assessment.metrics.priceMap, + ); + const result: ExecutionResult = { + intentId: authorization.intent.intentId, + vaultId: authorization.intent.vaultId, + chainId: "cardano", + sourceAssetId: simulated.sourceAssetId, + destinationAssetId: simulated.destinationAssetId, + soldAmount: simulated.soldAmount, + boughtAmount: simulated.boughtAmount, + averagePrice: simulated.averagePrice, + txHash: `tx-${randomUUID()}`, + executedAtUs: input.nowUs + 1_000, + routeId: simulated.routeId, + }; + + const completion = this.simulator.completeExecution({ + treasury: authorization.treasury, + policy: input.policy, + intent: authorization.intent, + result, + }); + + this.auditStore.recordExecution(result); + this.auditStore.recordEvent({ + eventId: `execution:${result.txHash}`, + category: "execution", + payload: { + intentId: result.intentId, + txHash: result.txHash, + soldAmount: result.soldAmount, + boughtAmount: result.boughtAmount, + stage: completion.treasury.stage, + }, + createdAtUs: result.executedAtUs, + }); + + return { + treasury: completion.treasury, + intentId: authorization.intent.intentId, + txHash: result.txHash, + stage: completion.treasury.stage, + }; + } catch (error) { + const message = + error instanceof CardanoExecutionError ? error.code : "UNKNOWN_ERROR"; + this.auditStore.recordEvent({ + eventId: `rejection:${randomUUID()}`, + category: "rejection", + payload: { + code: message, + keeperId: input.keeperId, + }, + createdAtUs: input.nowUs, + }); + + return { + treasury: input.treasury, + stage: input.treasury.stage, + rejected: message, + }; + } + } +} diff --git a/lazer/cardano/guards/source/apps/backend/src/preview-server.ts b/lazer/cardano/guards/source/apps/backend/src/preview-server.ts new file mode 100644 index 00000000..4c588c80 --- /dev/null +++ b/lazer/cardano/guards/source/apps/backend/src/preview-server.ts @@ -0,0 +1,88 @@ +import { createReadStream, existsSync } from "node:fs"; +import { stat } from "node:fs/promises"; +import { createServer } from "node:http"; +import path from "node:path"; +import { createDemoState } from "./demo-state.js"; +import { runtimeEnv } from "./env.js"; + +const port = runtimeEnv.port; +const uiRoot = path.resolve(process.cwd(), "apps/ui"); +const docsRoot = path.resolve(process.cwd(), "docs"); +const allowedDocs = new Set([ + "functional-v4.md", + "roadmap.md", + "landing-frontend-spec.md", + "cardano-swap-venue-decision.md", + "protocol-fee-model.md", + "cardano-custody-model.md", + "dexhunter-live-adapter.md", +]); + +const contentTypes: Record = { + ".html": "text/html; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".md": "text/markdown; charset=utf-8", +}; + +function resolveUiPath(requestPath: string): string { + const safePath = requestPath === "/" ? "/index.html" : requestPath; + const relativePath = safePath.replace(/^\/+/, ""); + const resolvedPath = path.resolve(uiRoot, relativePath); + const relativeToRoot = path.relative(uiRoot, resolvedPath); + + if (relativeToRoot.startsWith("..") || path.isAbsolute(relativeToRoot)) { + return path.resolve(uiRoot, "index.html"); + } + + return resolvedPath; +} + +function resolveDocsPath(requestPath: string): string | undefined { + const fileName = requestPath.replace(/^\/docs\/+/, ""); + if (!allowedDocs.has(fileName)) { + return undefined; + } + + return path.resolve(docsRoot, fileName); +} + +const server = createServer(async (request, response) => { + const hostHeader = + typeof request.headers.host === "string" && request.headers.host.length > 0 + ? request.headers.host + : "localhost"; + const url = new URL(request.url ?? "/", `http://${hostHeader}`); + + if (url.pathname === "/api/demo-state") { + response.writeHead(200, { "content-type": "application/json; charset=utf-8" }); + response.end(JSON.stringify(createDemoState().payload, null, 2)); + return; + } + + const filePath = url.pathname.startsWith("/docs/") + ? resolveDocsPath(url.pathname) + : resolveUiPath(url.pathname); + if (!filePath || !existsSync(filePath)) { + response.writeHead(404, { "content-type": "text/plain; charset=utf-8" }); + response.end("Not found"); + return; + } + + const fileStats = await stat(filePath); + if (!fileStats.isFile()) { + response.writeHead(403, { "content-type": "text/plain; charset=utf-8" }); + response.end("Forbidden"); + return; + } + + response.writeHead(200, { + "content-type": contentTypes[path.extname(filePath)] ?? "application/octet-stream", + }); + createReadStream(filePath).pipe(response); +}); + +server.listen(port, () => { + console.log(`Preview server running at http://localhost:${port}`); +}); diff --git a/lazer/cardano/guards/source/apps/backend/src/protocol-fee.ts b/lazer/cardano/guards/source/apps/backend/src/protocol-fee.ts new file mode 100644 index 00000000..c1b14815 --- /dev/null +++ b/lazer/cardano/guards/source/apps/backend/src/protocol-fee.ts @@ -0,0 +1,125 @@ +import type { CardanoSwapVenueConfig } from "./swap-venue.js"; +import { feeBpsToPercentPoints } from "./swap-venue.js"; + +export type ProtocolFeeMode = "none" | "explicit_output" | "post_swap_reconciliation"; + +export interface ProtocolFeePolicy { + provider: CardanoSwapVenueConfig["provider"]; + venueFeePercentPoints: number; + protocolFeeBps: number; + protocolFeePercentPoints: number; + maxTotalFeeBps: number; + maxTotalFeePercentPoints: number; + protocolFeeMode: ProtocolFeeMode; +} + +export interface RevenueBreakdown { + grossAmount: number; + venueFeeAmount: number; + protocolFeeAmount: number; + netAmount: number; + venueFeePercentPoints: number; + requestedProtocolFeePercentPoints: number; + effectiveProtocolFeePercentPoints: number; + totalFeePercentPoints: number; + totalFeeBps: number; + capped: boolean; + capExceededByVenueFee: boolean; + protocolFeeMode: ProtocolFeeMode; +} + +function clampPercent(value: number): number { + if (!Number.isFinite(value) || value < 0) { + return 0; + } + + return Number(value.toFixed(6)); +} + +export function resolveProtocolFeeMode(value: string): ProtocolFeeMode { + switch (value) { + case "none": + case "post_swap_reconciliation": + return value; + default: + return "explicit_output"; + } +} + +export function buildProtocolFeePolicy(input: { + provider: CardanoSwapVenueConfig["provider"]; + venueFeePercent: number; + protocolFeeBps: number; + maxTotalFeeBps: number; + protocolFeeMode: string; +}): ProtocolFeePolicy { + const protocolFeeBps = Math.max(0, Math.round(input.protocolFeeBps)); + const maxTotalFeeBps = Math.max(0, Math.round(input.maxTotalFeeBps)); + + return { + provider: input.provider, + venueFeePercentPoints: clampPercent(input.venueFeePercent), + protocolFeeBps, + protocolFeePercentPoints: feeBpsToPercentPoints(protocolFeeBps), + maxTotalFeeBps, + maxTotalFeePercentPoints: feeBpsToPercentPoints(maxTotalFeeBps), + protocolFeeMode: resolveProtocolFeeMode(input.protocolFeeMode), + }; +} + +export function getConfiguredVenueFeePercent(config: CardanoSwapVenueConfig): number { + if (config.provider === "dexhunter") { + return clampPercent(config.dexHunter.partnerFeePercentPoints); + } + + return 0; +} + +export function applyRevenueFees( + grossAmount: number, + policy: ProtocolFeePolicy, +): RevenueBreakdown { + const normalizedGrossAmount = Number.isFinite(grossAmount) && grossAmount > 0 ? grossAmount : 0; + const requestedProtocolFeePercentPoints = + policy.protocolFeeMode === "none" ? 0 : policy.protocolFeePercentPoints; + const totalRequestedFeePercentPoints = clampPercent( + policy.venueFeePercentPoints + requestedProtocolFeePercentPoints, + ); + const totalAllowedFeePercentPoints = policy.maxTotalFeePercentPoints; + const capExceededByVenueFee = policy.venueFeePercentPoints > totalAllowedFeePercentPoints; + const effectiveProtocolFeePercentPoints = clampPercent( + Math.max(0, totalAllowedFeePercentPoints - policy.venueFeePercentPoints), + ); + const capped = + totalRequestedFeePercentPoints > totalAllowedFeePercentPoints || capExceededByVenueFee; + const protocolFeePercentPoints = capped + ? effectiveProtocolFeePercentPoints + : requestedProtocolFeePercentPoints; + const totalFeePercentPoints = clampPercent( + policy.venueFeePercentPoints + protocolFeePercentPoints, + ); + const venueFeeAmount = Number( + ((normalizedGrossAmount * policy.venueFeePercentPoints) / 100).toFixed(8), + ); + const protocolFeeAmount = Number( + ((normalizedGrossAmount * protocolFeePercentPoints) / 100).toFixed(8), + ); + const netAmount = Number( + Math.max(0, normalizedGrossAmount - venueFeeAmount - protocolFeeAmount).toFixed(8), + ); + + return { + grossAmount: normalizedGrossAmount, + venueFeeAmount, + protocolFeeAmount, + netAmount, + venueFeePercentPoints: policy.venueFeePercentPoints, + requestedProtocolFeePercentPoints, + effectiveProtocolFeePercentPoints: protocolFeePercentPoints, + totalFeePercentPoints, + totalFeeBps: Math.round(totalFeePercentPoints * 100), + capped, + capExceededByVenueFee, + protocolFeeMode: policy.protocolFeeMode, + }; +} diff --git a/lazer/cardano/guards/source/apps/backend/src/risk-engine.ts b/lazer/cardano/guards/source/apps/backend/src/risk-engine.ts new file mode 100644 index 00000000..c014c069 --- /dev/null +++ b/lazer/cardano/guards/source/apps/backend/src/risk-engine.ts @@ -0,0 +1,20 @@ +import { + evaluateRiskLadder, + type OracleSnapshot, + type PolicyConfig, + type RiskAssessment, + type RouteSpec, + type TreasuryState, +} from "@anaconda/core"; + +export class RiskEngine { + evaluate( + treasury: TreasuryState, + policy: PolicyConfig, + snapshots: Record, + routes: RouteSpec[], + nowUs: number, + ): RiskAssessment { + return evaluateRiskLadder(treasury, policy, snapshots, routes, nowUs); + } +} diff --git a/lazer/cardano/guards/source/apps/backend/src/simulate.ts b/lazer/cardano/guards/source/apps/backend/src/simulate.ts new file mode 100644 index 00000000..09aa04dd --- /dev/null +++ b/lazer/cardano/guards/source/apps/backend/src/simulate.ts @@ -0,0 +1,4 @@ +import "./env.js"; +import { createDemoState } from "./demo-state.js"; + +console.log(JSON.stringify(createDemoState(), null, 2)); diff --git a/lazer/cardano/guards/source/apps/backend/src/storage.ts b/lazer/cardano/guards/source/apps/backend/src/storage.ts new file mode 100644 index 00000000..19ce6c40 --- /dev/null +++ b/lazer/cardano/guards/source/apps/backend/src/storage.ts @@ -0,0 +1,131 @@ +import { DatabaseSync } from "node:sqlite"; +import type { ExecutionIntent, ExecutionResult, OracleSnapshot } from "@anaconda/core"; + +export interface AuditEvent { + eventId: string; + category: "snapshot" | "intent" | "execution" | "rejection"; + payload: Record; + createdAtUs: number; +} + +export class AuditStore { + private readonly db: DatabaseSync; + + constructor(location = ":memory:") { + this.db = new DatabaseSync(location); + this.db.exec(` + CREATE TABLE IF NOT EXISTS snapshots ( + snapshot_id TEXT PRIMARY KEY, + asset_id TEXT NOT NULL, + payload TEXT NOT NULL, + observed_at_us INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS intents ( + intent_id TEXT PRIMARY KEY, + vault_id TEXT NOT NULL, + payload TEXT NOT NULL, + created_at_us INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS executions ( + tx_hash TEXT PRIMARY KEY, + intent_id TEXT NOT NULL, + payload TEXT NOT NULL, + executed_at_us INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS audit_events ( + event_id TEXT PRIMARY KEY, + category TEXT NOT NULL, + payload TEXT NOT NULL, + created_at_us INTEGER NOT NULL + ); + `); + } + + recordSnapshot(snapshot: OracleSnapshot) { + this.db + .prepare(` + INSERT OR REPLACE INTO snapshots (snapshot_id, asset_id, payload, observed_at_us) + VALUES (?, ?, ?, ?) + `) + .run( + snapshot.snapshotId, + snapshot.assetId, + JSON.stringify(snapshot), + snapshot.observedAtUs, + ); + } + + recordIntent(intent: ExecutionIntent) { + this.db + .prepare(` + INSERT OR REPLACE INTO intents (intent_id, vault_id, payload, created_at_us) + VALUES (?, ?, ?, ?) + `) + .run(intent.intentId, intent.vaultId, JSON.stringify(intent), intent.createdAtUs); + } + + recordExecution(result: ExecutionResult) { + this.db + .prepare(` + INSERT OR REPLACE INTO executions (tx_hash, intent_id, payload, executed_at_us) + VALUES (?, ?, ?, ?) + `) + .run(result.txHash, result.intentId, JSON.stringify(result), result.executedAtUs); + } + + recordEvent(event: AuditEvent) { + this.db + .prepare(` + INSERT OR REPLACE INTO audit_events (event_id, category, payload, created_at_us) + VALUES (?, ?, ?, ?) + `) + .run( + event.eventId, + event.category, + JSON.stringify(event.payload), + event.createdAtUs, + ); + } + + listEvents(): AuditEvent[] { + const rows = this.db + .prepare( + `SELECT event_id, category, payload, created_at_us FROM audit_events ORDER BY created_at_us, event_id`, + ) + .all() as Array<{ + event_id: string; + category: AuditEvent["category"]; + payload: string; + created_at_us: number; + }>; + + return rows.map((row) => ({ + eventId: row.event_id, + category: row.category, + payload: JSON.parse(row.payload) as Record, + createdAtUs: row.created_at_us, + })); + } + + counts() { + const [snapshots] = this.db + .prepare(`SELECT COUNT(*) AS count FROM snapshots`) + .all() as Array<{ count: number }>; + const [intents] = this.db + .prepare(`SELECT COUNT(*) AS count FROM intents`) + .all() as Array<{ count: number }>; + const [executions] = this.db + .prepare(`SELECT COUNT(*) AS count FROM executions`) + .all() as Array<{ count: number }>; + const [events] = this.db + .prepare(`SELECT COUNT(*) AS count FROM audit_events`) + .all() as Array<{ count: number }>; + + return { + snapshots: snapshots?.count ?? 0, + intents: intents?.count ?? 0, + executions: executions?.count ?? 0, + events: events?.count ?? 0, + }; + } +} diff --git a/lazer/cardano/guards/source/apps/backend/src/swap-venue.ts b/lazer/cardano/guards/source/apps/backend/src/swap-venue.ts new file mode 100644 index 00000000..782283be --- /dev/null +++ b/lazer/cardano/guards/source/apps/backend/src/swap-venue.ts @@ -0,0 +1,100 @@ +import { runtimeEnv } from "./env.js"; + +export type CardanoSwapProvider = "dexhunter" | "minswap"; + +const MAX_FEE_BPS = 10_000; +const MAX_PERCENT_POINTS = 100; + +export interface CardanoSwapVenueConfig { + provider: CardanoSwapProvider; + protocolFeeBps: number; + protocolFeePercentPoints: number; + protocolFeeRate: number; + dexHunter: { + baseUrl: string; + partnerId: string; + partnerFeePercentPoints: number; + partnerFeeRate: number; + requiresPartnerHeader: boolean; + }; + minswap: { + aggregatorUrl: string; + partnerCode: string; + supportsPartnerTracking: boolean; + }; +} + +export function resolveCardanoSwapProvider(value: string): CardanoSwapProvider { + const normalized = value.trim().toLowerCase(); + return normalized === "minswap" ? "minswap" : "dexhunter"; +} + +function clampBps(value: number): number { + if (!Number.isFinite(value)) { + return 0; + } + + return Math.min(MAX_FEE_BPS, Math.max(0, Math.round(value))); +} + +function clampPercentPoints(value: number): number { + if (!Number.isFinite(value)) { + return 0; + } + + return Math.min(MAX_PERCENT_POINTS, Math.max(0, Number(value.toFixed(4)))); +} + +export function feeBpsToPercentPoints(bps: number): number { + return clampBps(bps) / 100; +} + +export function feeBpsToRate(bps: number): number { + return clampBps(bps) / 10_000; +} + +export function buildCardanoSwapVenueConfig(input: { + provider: string; + protocolFeeBps: number; + dexHunterBaseUrl: string; + dexHunterPartnerId: string; + dexHunterPartnerFeePercent: number; + minswapAggregatorUrl: string; + minswapPartnerCode: string; +}): CardanoSwapVenueConfig { + const protocolFeeBps = clampBps(input.protocolFeeBps); + const dexHunterPartnerFeePercentPoints = clampPercentPoints( + input.dexHunterPartnerFeePercent, + ); + + return { + provider: resolveCardanoSwapProvider(input.provider), + protocolFeeBps, + protocolFeePercentPoints: feeBpsToPercentPoints(protocolFeeBps), + protocolFeeRate: feeBpsToRate(protocolFeeBps), + dexHunter: { + baseUrl: input.dexHunterBaseUrl, + partnerId: input.dexHunterPartnerId, + partnerFeePercentPoints: dexHunterPartnerFeePercentPoints, + partnerFeeRate: dexHunterPartnerFeePercentPoints / 100, + requiresPartnerHeader: input.dexHunterPartnerId.length > 0, + }, + minswap: { + aggregatorUrl: input.minswapAggregatorUrl, + partnerCode: input.minswapPartnerCode, + supportsPartnerTracking: input.minswapPartnerCode.length > 0, + }, + }; +} + +export function getCardanoSwapVenueConfig(): CardanoSwapVenueConfig { + return buildCardanoSwapVenueConfig({ + provider: runtimeEnv.cardanoSwapProvider, + protocolFeeBps: runtimeEnv.cardanoProtocolFeeBps, + dexHunterBaseUrl: runtimeEnv.dexHunterBaseUrl, + dexHunterPartnerId: runtimeEnv.dexHunterPartnerId, + dexHunterPartnerFeePercent: runtimeEnv.dexHunterPartnerFeePercent, + minswapAggregatorUrl: runtimeEnv.minswapAggregatorUrl, + minswapPartnerCode: runtimeEnv.minswapPartnerCode, + }); +} diff --git a/lazer/cardano/guards/source/apps/backend/tests/dashboard.test.ts b/lazer/cardano/guards/source/apps/backend/tests/dashboard.test.ts new file mode 100644 index 00000000..c1792d8d --- /dev/null +++ b/lazer/cardano/guards/source/apps/backend/tests/dashboard.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { createDemoState } from "../src/demo-state.js"; + +describe("dashboard payload", () => { + it("builds a UI payload from the simulated backend state", () => { + const state = createDemoState(); + + expect(state.payload.source).toBe("backend-demo"); + expect(state.payload.workspace.name).toContain("Treasury"); + expect(state.payload.topbarChips).toHaveLength(3); + expect(state.payload.heroMetrics).toHaveLength(4); + expect(state.payload.dashboardCards).toHaveLength(4); + expect(state.payload.chainCards).toHaveLength(3); + expect(state.payload.accounts).toHaveLength(2); + expect(state.payload.portfolioSeries).toHaveLength(4); + expect(state.payload.demoFrames).toHaveLength(4); + expect(state.payload.executionTimeline).toHaveLength(3); + expect(state.payload.auditTrail.length).toBeGreaterThan(0); + expect(state.operations.reentry.stage).toBe("normal"); + }); +}); diff --git a/lazer/cardano/guards/source/apps/backend/tests/dexhunter-live.test.ts b/lazer/cardano/guards/source/apps/backend/tests/dexhunter-live.test.ts new file mode 100644 index 00000000..785dd15b --- /dev/null +++ b/lazer/cardano/guards/source/apps/backend/tests/dexhunter-live.test.ts @@ -0,0 +1,219 @@ +import { describe, expect, it, vi } from "vitest"; +import { PolicyVaultSimulator } from "@anaconda/cardano"; +import { buildDemoScenario, sampleWitness } from "../src/fixtures.js"; +import { + DexHunterLiveAdapter, + DexHunterLiveError, + type CardanoHotWallet, +} from "../src/dexhunter-live.js"; +import { CardanoDexHunterKeeperService } from "../src/dexhunter-keeper.js"; +import { AuditStore } from "../src/storage.js"; +import { buildCardanoSwapVenueConfig } from "../src/swap-venue.js"; + +function createMockWallet(): CardanoHotWallet { + return { + address: "addr_test_hot_1", + signTx: vi.fn(async () => "witness-set"), + submitTx: vi.fn(async () => "tx-live-123"), + }; +} + +function createFetchMock() { + return vi.fn(async (input: string | URL, init?: RequestInit) => { + const url = input.toString(); + const body = JSON.parse((init?.body as string | undefined) ?? "{}"); + + if (url.endsWith("/swap/estimate")) { + return new Response( + JSON.stringify({ + total_output: 5100, + total_output_without_slippage: 5150, + possible_routes: [{ dex: "MINSWAPV2", amount_in: body.amount_in, expected_output: 5100 }], + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } + + if (url.endsWith("/swap/build")) { + return new Response( + JSON.stringify({ + cbor: "cbor-build-123", + total_input: body.amount_in, + total_output: 5100, + splits: [{ dex: "MINSWAPV2", amount_in: body.amount_in, expected_output: 5100 }], + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } + + if (url.endsWith("/swap/sign")) { + return new Response( + JSON.stringify({ cbor: "cbor-signed-123" }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } + + return new Response("not found", { status: 404 }); + }); +} + +function createAdapter(fetchMock: typeof fetch) { + return new DexHunterLiveAdapter(fetchMock, { + venueConfig: buildCardanoSwapVenueConfig({ + provider: "dexhunter", + protocolFeeBps: 30, + dexHunterBaseUrl: "https://api-us.dexhunterv3.app", + dexHunterPartnerId: "partner-live-test", + dexHunterPartnerFeePercent: 0.3, + minswapAggregatorUrl: "https://agg-api.minswap.org/aggregator", + minswapPartnerCode: "guards-one", + }), + maxTotalFeeBps: 100, + protocolFeeMode: "explicit_output", + }); +} + +describe("dexhunter live adapter", () => { + it("executes an authorized intent through estimate/build/sign/submit", async () => { + const scenario = buildDemoScenario(); + const authorization = new PolicyVaultSimulator(sampleWitness.pythPolicyId).authorizeExecution({ + treasury: scenario.treasury, + policy: scenario.policy, + snapshots: scenario.snapshots, + routes: scenario.routes, + nowUs: 200_000_000, + keeperId: "keeper-1", + witness: sampleWitness, + }); + const fetchMock = createFetchMock(); + const wallet = createMockWallet(); + const adapter = createAdapter(fetchMock as typeof fetch); + + const outcome = await adapter.executeIntent({ + intent: authorization.intent, + routes: scenario.routes, + wallet, + assetTokenIds: { usdm: "usdm-token-id" }, + nowUs: 200_001_000, + }); + + expect(outcome.result.txHash).toBe("tx-live-123"); + expect(outcome.result.soldAmount).toBe(authorization.intent.maxSellAmount); + expect(outcome.result.boughtAmount).toBe(5100); + expect(outcome.revenueBreakdown.totalFeeBps).toBeGreaterThanOrEqual(30); + expect(fetchMock).toHaveBeenCalledTimes(3); + }); + + it("rejects a quote below the minimum buy amount", async () => { + const scenario = buildDemoScenario(); + const authorization = new PolicyVaultSimulator(sampleWitness.pythPolicyId).authorizeExecution({ + treasury: scenario.treasury, + policy: scenario.policy, + snapshots: scenario.snapshots, + routes: scenario.routes, + nowUs: 200_000_000, + keeperId: "keeper-1", + witness: sampleWitness, + }); + const fetchMock = vi.fn(async () => + new Response( + JSON.stringify({ + total_output: 1, + total_output_without_slippage: 1, + possible_routes: [], + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + const wallet = createMockWallet(); + const adapter = createAdapter(fetchMock as typeof fetch); + + await expect( + adapter.executeIntent({ + intent: authorization.intent, + routes: scenario.routes, + wallet, + assetTokenIds: { usdm: "usdm-token-id" }, + nowUs: 200_001_000, + }), + ).rejects.toMatchObject({ code: "QUOTE_BELOW_MIN_BUY" } satisfies Partial); + }); + + it("rejects execution when the intent has expired", async () => { + const scenario = buildDemoScenario(); + const authorization = new PolicyVaultSimulator(sampleWitness.pythPolicyId).authorizeExecution({ + treasury: scenario.treasury, + policy: scenario.policy, + snapshots: scenario.snapshots, + routes: scenario.routes, + nowUs: 200_000_000, + keeperId: "keeper-1", + witness: sampleWitness, + }); + const adapter = createAdapter(createFetchMock() as typeof fetch); + + await expect( + adapter.executeIntent({ + intent: authorization.intent, + routes: scenario.routes, + wallet: createMockWallet(), + assetTokenIds: { usdm: "usdm-token-id" }, + nowUs: authorization.intent.expiryUs + 1, + }), + ).rejects.toMatchObject({ code: "INTENT_EXPIRED" } satisfies Partial); + }); + + it("rejects execution before the intent becomes valid", async () => { + const scenario = buildDemoScenario(); + const authorization = new PolicyVaultSimulator(sampleWitness.pythPolicyId).authorizeExecution({ + treasury: scenario.treasury, + policy: scenario.policy, + snapshots: scenario.snapshots, + routes: scenario.routes, + nowUs: 200_000_000, + keeperId: "keeper-1", + witness: sampleWitness, + }); + const adapter = createAdapter(createFetchMock() as typeof fetch); + + await expect( + adapter.executeIntent({ + intent: authorization.intent, + routes: scenario.routes, + wallet: createMockWallet(), + assetTokenIds: { usdm: "usdm-token-id" }, + nowUs: authorization.intent.createdAtUs - 1, + }), + ).rejects.toMatchObject({ code: "INTENT_NOT_YET_VALID" } satisfies Partial); + }); + + it("records a live execution path in the keeper audit log", async () => { + const scenario = buildDemoScenario(); + const auditStore = new AuditStore(); + const fetchMock = createFetchMock(); + const wallet = createMockWallet(); + const keeper = new CardanoDexHunterKeeperService( + sampleWitness.pythPolicyId, + auditStore, + createAdapter(fetchMock as typeof fetch), + ); + + const result = await keeper.tick({ + treasury: scenario.treasury, + policy: scenario.policy, + routes: scenario.routes, + snapshots: scenario.snapshots, + nowUs: 200_000_000, + keeperId: "keeper-1", + witness: sampleWitness, + wallet, + assetTokenIds: { usdm: "usdm-token-id" }, + }); + + expect(result.txHash).toBe("tx-live-123"); + const executionEvent = auditStore + .listEvents() + .find((event) => event.category === "execution"); + expect(executionEvent?.payload.totalFeeBps).toBeDefined(); + }); +}); diff --git a/lazer/cardano/guards/source/apps/backend/tests/e2e.test.ts b/lazer/cardano/guards/source/apps/backend/tests/e2e.test.ts new file mode 100644 index 00000000..d911b220 --- /dev/null +++ b/lazer/cardano/guards/source/apps/backend/tests/e2e.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest"; +import { buildSnapshots } from "@anaconda/core"; +import { PythCollector } from "../src/collector.js"; +import { createDemoState } from "../src/demo-state.js"; +import { buildDemoScenario, sampleWitness } from "../src/fixtures.js"; +import { CardanoKeeperService } from "../src/keeper.js"; +import { AuditStore } from "../src/storage.js"; + +describe("backend e2e flow", () => { + it("runs partial de-risk, full exit, reentry and records an audit trail", () => { + const state = createDemoState(); + const { partial, fullExit, reentry } = state.operations; + + expect(partial.stage).toBe("partial_derisk"); + expect(partial.txHash).toBeDefined(); + expect(fullExit.stage).toBe("full_exit"); + expect(fullExit.txHash).toBeDefined(); + expect(reentry.stage).toBe("normal"); + expect(reentry.txHash).toBeDefined(); + expect(state.counts.snapshots).toBeGreaterThanOrEqual(6); + expect(state.counts.intents).toBe(3); + expect(state.counts.executions).toBe(3); + expect(state.counts.events).toBeGreaterThanOrEqual(9); + }); + + it("records a rejection when oracle freshness is invalid", () => { + const scenario = buildDemoScenario(); + const auditStore = new AuditStore(); + const collector = new PythCollector(auditStore); + const keeper = new CardanoKeeperService(sampleWitness.pythPolicyId, auditStore); + + for (const snapshot of Object.values( + buildSnapshots({ + ada: { + snapshotId: "snapshot-ada-stale", + feedUpdateTimestampUs: 1_000_000, + observedAtUs: 1_000_000, + }, + }), + )) { + collector.publish(snapshot); + } + + const result = keeper.tick({ + treasury: scenario.treasury, + policy: scenario.policy, + routes: scenario.routes, + snapshots: collector.current(), + nowUs: 500_000_000, + keeperId: "keeper-1", + witness: sampleWitness, + }); + + expect(result.rejected).toBe("STALE_FEED"); + expect(auditStore.counts().events).toBeGreaterThanOrEqual(3); + }); + + it("records a rejection when no approved route exists for execution", () => { + const scenario = buildDemoScenario(); + const auditStore = new AuditStore(); + const collector = new PythCollector(auditStore); + const keeper = new CardanoKeeperService(sampleWitness.pythPolicyId, auditStore); + + for (const snapshot of Object.values(scenario.snapshots)) { + collector.publish(snapshot); + } + + const result = keeper.tick({ + treasury: scenario.treasury, + policy: { + ...scenario.policy, + approvedRouteIds: [], + }, + routes: scenario.routes, + snapshots: collector.current(), + nowUs: 200_000_000, + keeperId: "keeper-1", + witness: sampleWitness, + }); + + expect(result.rejected).toBe("NO_EXECUTABLE_INTENT"); + }); +}); diff --git a/lazer/cardano/guards/source/apps/backend/tests/protocol-fee.test.ts b/lazer/cardano/guards/source/apps/backend/tests/protocol-fee.test.ts new file mode 100644 index 00000000..9b703290 --- /dev/null +++ b/lazer/cardano/guards/source/apps/backend/tests/protocol-fee.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from "vitest"; +import { + applyRevenueFees, + buildProtocolFeePolicy, + getConfiguredVenueFeePercent, + resolveProtocolFeeMode, +} from "../src/protocol-fee.js"; +import { buildCardanoSwapVenueConfig } from "../src/swap-venue.js"; + +describe("protocol fee model", () => { + it("defaults unknown modes to explicit output", () => { + expect(resolveProtocolFeeMode("something-else")).toBe("explicit_output"); + expect(resolveProtocolFeeMode("none")).toBe("none"); + expect(resolveProtocolFeeMode("post_swap_reconciliation")).toBe( + "post_swap_reconciliation", + ); + }); + + it("reads configured venue fee from dexhunter", () => { + const config = buildCardanoSwapVenueConfig({ + provider: "dexhunter", + protocolFeeBps: 30, + dexHunterBaseUrl: "https://api-us.dexhunterv3.app", + dexHunterPartnerId: "partner-123", + dexHunterPartnerFeePercent: 0.3, + minswapAggregatorUrl: "https://agg-api.minswap.org/aggregator", + minswapPartnerCode: "guards-one", + }); + + expect(getConfiguredVenueFeePercent(config)).toBe(0.3); + }); + + it("applies venue fee plus protocol fee when within cap", () => { + const breakdown = applyRevenueFees( + 1_000, + buildProtocolFeePolicy({ + provider: "dexhunter", + venueFeePercent: 0.3, + protocolFeeBps: 70, + maxTotalFeeBps: 100, + protocolFeeMode: "explicit_output", + }), + ); + + expect(breakdown.venueFeeAmount).toBe(3); + expect(breakdown.protocolFeeAmount).toBe(7); + expect(breakdown.netAmount).toBe(990); + expect(breakdown.totalFeeBps).toBe(100); + expect(breakdown.totalFeePercentPoints).toBe(1); + expect(breakdown.capped).toBe(false); + }); + + it("caps protocol fee when venue fee plus protocol fee exceed the max", () => { + const breakdown = applyRevenueFees( + 1_000, + buildProtocolFeePolicy({ + provider: "dexhunter", + venueFeePercent: 0.45, + protocolFeeBps: 80, + maxTotalFeeBps: 100, + protocolFeeMode: "explicit_output", + }), + ); + + expect(breakdown.requestedProtocolFeePercentPoints).toBe(0.8); + expect(breakdown.effectiveProtocolFeePercentPoints).toBe(0.55); + expect(breakdown.protocolFeeAmount).toBe(5.5); + expect(breakdown.totalFeeBps).toBe(100); + expect(breakdown.capped).toBe(true); + }); + + + it("flags when the venue fee alone exceeds the cap", () => { + const breakdown = applyRevenueFees( + 1_000, + buildProtocolFeePolicy({ + provider: "dexhunter", + venueFeePercent: 1.2, + protocolFeeBps: 20, + maxTotalFeeBps: 100, + protocolFeeMode: "explicit_output", + }), + ); + + expect(breakdown.capExceededByVenueFee).toBe(true); + expect(breakdown.effectiveProtocolFeePercentPoints).toBe(0); + expect(breakdown.totalFeeBps).toBe(120); + expect(breakdown.capped).toBe(true); + }); + + it("disables protocol fee when the mode is none", () => { + const breakdown = applyRevenueFees( + 500, + buildProtocolFeePolicy({ + provider: "minswap", + venueFeePercent: 0, + protocolFeeBps: 90, + maxTotalFeeBps: 100, + protocolFeeMode: "none", + }), + ); + + expect(breakdown.protocolFeeAmount).toBe(0); + expect(breakdown.totalFeeBps).toBe(0); + }); +}); diff --git a/lazer/cardano/guards/source/apps/backend/tests/storage.test.ts b/lazer/cardano/guards/source/apps/backend/tests/storage.test.ts new file mode 100644 index 00000000..47c037bb --- /dev/null +++ b/lazer/cardano/guards/source/apps/backend/tests/storage.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import { buildSnapshots } from "@anaconda/core"; +import { PythCollector } from "../src/collector.js"; +import { AuditStore } from "../src/storage.js"; + +describe("audit store and collector", () => { + it("records snapshots and returns event counts", () => { + const store = new AuditStore(); + const collector = new PythCollector(store); + const snapshots = buildSnapshots(); + + collector.publish(snapshots.ada!); + collector.publish(snapshots.usdm!); + + const current = collector.current(); + expect(current.ada!.snapshotId).toBe("snapshot-ada"); + expect(store.counts().snapshots).toBe(2); + expect(store.counts().events).toBe(2); + expect(store.listEvents()[0]?.category).toBe("snapshot"); + }); + + it("overwrites the latest snapshot per asset in collector state", () => { + const collector = new PythCollector(); + collector.publish(buildSnapshots().ada!); + collector.publish( + buildSnapshots({ + ada: { + snapshotId: "snapshot-ada-2", + observedAtUs: 2_000_000, + feedUpdateTimestampUs: 2_000_000, + }, + }).ada!, + ); + + expect(collector.current().ada!.snapshotId).toBe("snapshot-ada-2"); + }); + + it("orders audit events deterministically when timestamps tie", () => { + const store = new AuditStore(); + + store.recordEvent({ + eventId: "event-b", + category: "snapshot", + payload: { order: 2 }, + createdAtUs: 1_000_000, + }); + store.recordEvent({ + eventId: "event-a", + category: "snapshot", + payload: { order: 1 }, + createdAtUs: 1_000_000, + }); + + expect(store.listEvents().map((event) => event.eventId)).toEqual(["event-a", "event-b"]); + }); +}); diff --git a/lazer/cardano/guards/source/apps/backend/tests/swap-venue.test.ts b/lazer/cardano/guards/source/apps/backend/tests/swap-venue.test.ts new file mode 100644 index 00000000..5bc8d108 --- /dev/null +++ b/lazer/cardano/guards/source/apps/backend/tests/swap-venue.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { + buildCardanoSwapVenueConfig, + feeBpsToPercentPoints, + feeBpsToRate, + resolveCardanoSwapProvider, +} from "../src/swap-venue.js"; + +describe("swap venue config", () => { + it("normalizes provider values and defaults unknown providers to dexhunter", () => { + expect(resolveCardanoSwapProvider("something-else")).toBe("dexhunter"); + expect(resolveCardanoSwapProvider("dexhunter")).toBe("dexhunter"); + expect(resolveCardanoSwapProvider("minswap")).toBe("minswap"); + expect(resolveCardanoSwapProvider(" MINSWAP ")).toBe("minswap"); + }); + + it("normalizes fee bps to percent points and rate", () => { + expect(feeBpsToPercentPoints(30)).toBe(0.3); + expect(feeBpsToRate(30)).toBe(0.003); + expect(feeBpsToPercentPoints(0)).toBe(0); + expect(feeBpsToPercentPoints(-10)).toBe(0); + expect(feeBpsToPercentPoints(Number.NaN)).toBe(0); + expect(feeBpsToPercentPoints(20_000)).toBe(100); + }); + + it("builds venue config with bounded partner metadata", () => { + const config = buildCardanoSwapVenueConfig({ + provider: " MINSWAP ", + protocolFeeBps: 45, + dexHunterBaseUrl: "https://api-us.dexhunterv3.app", + dexHunterPartnerId: "partner-123", + dexHunterPartnerFeePercent: Number.NaN, + minswapAggregatorUrl: "https://agg-api.minswap.org/aggregator", + minswapPartnerCode: "guards-one", + }); + + expect(config.provider).toBe("minswap"); + expect(config.protocolFeeBps).toBe(45); + expect(config.protocolFeePercentPoints).toBe(0.45); + expect(config.protocolFeeRate).toBe(0.0045); + expect(config.dexHunter.requiresPartnerHeader).toBe(true); + expect(config.dexHunter.partnerFeePercentPoints).toBe(0); + expect(config.dexHunter.partnerFeeRate).toBe(0); + expect(config.minswap.supportsPartnerTracking).toBe(true); + }); +}); diff --git a/lazer/cardano/guards/source/apps/blockchain/README.md b/lazer/cardano/guards/source/apps/blockchain/README.md new file mode 100644 index 00000000..aec0f5e8 --- /dev/null +++ b/lazer/cardano/guards/source/apps/blockchain/README.md @@ -0,0 +1,48 @@ +# apps/blockchain + +This workspace is the team-facing entrypoint for all chain, contract, oracle-witness and execution integration work. + +The current implementation still uses `packages/*` as the source of truth for reusable adapters and simulators. This app exists so the team has one obvious place to iterate on: + +- Cardano smart contracts / `Aiken` +- off-chain transaction builders +- Pyth witness integration +- swap-venue adapters +- future SVM / EVM execution connectors + +## Current rule + +Start new blockchain-facing work under `apps/blockchain/*`. + +Use `packages/*` for shared libraries and stable adapter code that needs to be imported by backend/UI/tests. + +## Layout + +- `cardano/contracts`: target home for `Aiken` validators and related test vectors +- `cardano/offchain`: target home for transaction builders, witness preparation and execution services +- `svm`: collaboration surface for Solana/SVM work +- `evm`: collaboration surface for EVM work +- `src/manifests.ts`: machine-readable map of the current source of truth and next planned artifacts + +## Today vs next + +Today: +- Cardano simulator/spec: `packages/cardano` +- Cardano Aiken scaffold: `apps/blockchain/cardano/contracts/aiken` +- backend orchestration: `apps/backend` +- live Pyth collector + witness wiring: `apps/blockchain/cardano/offchain` +- multichain scaffolding: `packages/svm`, `packages/evm` + +Import the live collector explicitly from `@anaconda/blockchain/cardano/offchain`. +The generic `@anaconda/blockchain/cardano` entrypoint stays side-effect free and does not load dotenv or the Pyth SDK. + +Next: +- move more live off-chain execution work into `apps/blockchain/cardano/offchain` +- keep reusable types/engines in `packages/core` + +## Team workflow + +1. Open a feature branch from `main`. +2. Put new chain-facing files under `apps/blockchain/*`. +3. If the change becomes reusable across apps, promote it into `packages/*` in a follow-up commit. +4. Update `NEXT_STEPS.md` when introducing or closing any blockchain task. diff --git a/lazer/cardano/guards/source/apps/blockchain/cardano/contracts/README.md b/lazer/cardano/guards/source/apps/blockchain/cardano/contracts/README.md new file mode 100644 index 00000000..1b7a97e3 --- /dev/null +++ b/lazer/cardano/guards/source/apps/blockchain/cardano/contracts/README.md @@ -0,0 +1,42 @@ +# Cardano Contracts + +This directory now holds the live Cardano on-chain collaboration surface. + +Current source of truth here: + +- `aiken/aiken.toml` +- `aiken/lib/guards_one/types.ak` +- `aiken/lib/guards_one/policy_vault.ak` +- `aiken/validators/policy_vault.ak` +- `aiken/test/policy_vault.ak` + +What this scaffold already captures: + +- datum/redeemer modeling for `PolicyVault` +- payload-carrying authorize/complete redeemers +- keeper allowlist and route/asset approval helpers +- in-flight intent continuity +- hot-bucket max-notional guard helpers +- completion bounds for `sold_amount` / `bought_amount` +- fail-closed validator spend branches for authorize/complete until continuity checks land +- governance emergency withdraw recovery path + +Still pending: + +- custody-safe continuing-output checks for `AuthorizeExecution` / `CompleteExecution` +- recreated-output continuity for `UpdatePolicy` / `Resume` +- compiled artifacts / addresses for preprod in an environment with `aiken` +- witness and datum/redeemer test vectors with the Aiken toolchain +- explicit execution-bucket validator scaffold + +Reference simulator/spec: + +- `packages/cardano/src/policy-vault.ts` +- `packages/cardano/src/types.ts` + +Operational guide: + +- [`docs/preprod-vault-bootstrap.md`](../../../../docs/preprod-vault-bootstrap.md) +- `aiken/scripts/doctor.sh` +- `aiken/scripts/build.sh` +- `aiken/scripts/derive-address.sh` diff --git a/lazer/cardano/guards/source/apps/blockchain/cardano/contracts/aiken/README.md b/lazer/cardano/guards/source/apps/blockchain/cardano/contracts/aiken/README.md new file mode 100644 index 00000000..7bfc79e3 --- /dev/null +++ b/lazer/cardano/guards/source/apps/blockchain/cardano/contracts/aiken/README.md @@ -0,0 +1,61 @@ +# guards.one Cardano Aiken Scaffold + +This directory is the bounded scaffold for the on-chain port of the current `PolicyVault` / hot-bucket model. + +It mirrors the invariants currently enforced in TypeScript in [`packages/cardano/src/policy-vault.ts`](../../../../../packages/cardano/src/policy-vault.ts) and the custody model described in [`docs/cardano-custody-model.md`](../../../../../docs/cardano-custody-model.md). + +## Scope + +- Typed datum/redeemer skeletons for the vault state machine. +- Governance emergency recovery wired in the validator. +- `AuthorizeExecution` and `CompleteExecution` modeled in the helper layer but kept fail-closed in the spend validator until custody-safe continuity checks are implemented. +- Preprod-oriented shell scripts for toolchain checks, blueprint build, script export, script hash, and address derivation. +- Aiken tooling is not vendored or installed by default; build scripts assume `aiken` is available on `PATH`. + +## What this scaffold captures + +- governance signers and keeper allowlists +- approved route ids and asset allowlists +- cooldown and stage transitions +- pure helper checks for Pyth witness shape in the authorize path +- bounded hot-bucket execution rather than direct multisig spending +- current-intent continuity plus bounded result settlement +- keeper signature checks via `extra_signatories` +- governance-controlled emergency withdraw path + +## What is intentionally missing + +- real on-chain Pyth payload verification +- recreated-output continuity checks for `UpdatePolicy` / `Resume` +- live DEX/aggregator integration + +## Files + +- `lib/guards_one/types.ak`: datum, redeemer, and helper types. +- `lib/guards_one/policy_vault.ak`: scaffolded policy logic for the vault state machine. +- `validators/policy_vault.ak`: validator entrypoint wiring. +- `test/policy_vault.ak`: scenario catalog for the planned Aiken test suite. +- `scripts/doctor.sh`: checks required toolchain and env for preprod work. +- `scripts/build.sh`: runs `aiken build`, exports `policy_vault.plutus`, and writes the script hash. +- `scripts/derive-address.sh`: derives the script address and script hash directly from the Aiken blueprint. + +## Deploy-prep workflow + +```sh +pnpm cardano:contract:doctor +pnpm cardano:contract:build +pnpm cardano:contract:address +``` + +Notes: + +- `pnpm cardano:contract:build` requires `aiken` on `PATH`. +- `pnpm cardano:contract:address` also uses `aiken`, not `cardano-cli`. +- `aiken build` generates `plutus.json`, and the build script exports `artifacts/policy_vault.plutus` plus `artifacts/policy_vault.hash`. +- `AuthorizeExecution`, `CompleteExecution`, `UpdatePolicy`, and `Resume` remain fail-closed in the current validator. +- `UpdatePolicy` and `Resume` remain fail-closed until recreated-output continuity is modeled in the validator. + +Current generated preprod artifacts in this branch: + +- script hash: `d86fb4d585d62b644b02e9c41944b3c23c966b02c768d99aed4a85a7` +- script address: `addr_test1wrvxldx4shtzkeztqt5ugx2yk0pre9ntqtrk3kv6a49gtfc64y7rs` diff --git a/lazer/cardano/guards/source/apps/blockchain/cardano/contracts/aiken/aiken.lock b/lazer/cardano/guards/source/apps/blockchain/cardano/contracts/aiken/aiken.lock new file mode 100644 index 00000000..1a4fdb1c --- /dev/null +++ b/lazer/cardano/guards/source/apps/blockchain/cardano/contracts/aiken/aiken.lock @@ -0,0 +1,15 @@ +# This file was generated by Aiken +# You typically do not need to edit this file + +[[requirements]] +name = "aiken-lang/stdlib" +version = "v3.0.0" +source = "github" + +[[packages]] +name = "aiken-lang/stdlib" +version = "v3.0.0" +requirements = [] +source = "github" + +[etags] diff --git a/lazer/cardano/guards/source/apps/blockchain/cardano/contracts/aiken/aiken.toml b/lazer/cardano/guards/source/apps/blockchain/cardano/contracts/aiken/aiken.toml new file mode 100644 index 00000000..08c739c8 --- /dev/null +++ b/lazer/cardano/guards/source/apps/blockchain/cardano/contracts/aiken/aiken.toml @@ -0,0 +1,18 @@ +name = "solx-ar/guards-one-cardano-aiken" +version = "0.0.0" +compiler = "v1.1.21" +plutus = "v3" +license = "Apache-2.0" +description = "Bounded Aiken scaffold for the guards.one PolicyVault and hot-bucket model" + +[repository] +user = "solx-ar" +project = "guards-one-cardano-aiken" +platform = "github" + +[[dependencies]] +source = "github" +name = "aiken-lang/stdlib" +version = "v3.0.0" + +[config] diff --git a/lazer/cardano/guards/source/apps/blockchain/cardano/contracts/aiken/artifacts/.gitkeep b/lazer/cardano/guards/source/apps/blockchain/cardano/contracts/aiken/artifacts/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/lazer/cardano/guards/source/apps/blockchain/cardano/contracts/aiken/artifacts/.gitkeep @@ -0,0 +1 @@ + diff --git a/lazer/cardano/guards/source/apps/blockchain/cardano/contracts/aiken/artifacts/policy_vault.addr b/lazer/cardano/guards/source/apps/blockchain/cardano/contracts/aiken/artifacts/policy_vault.addr new file mode 100644 index 00000000..34db4a14 --- /dev/null +++ b/lazer/cardano/guards/source/apps/blockchain/cardano/contracts/aiken/artifacts/policy_vault.addr @@ -0,0 +1 @@ +addr_test1wrvxldx4shtzkeztqt5ugx2yk0pre9ntqtrk3kv6a49gtfc64y7rs diff --git a/lazer/cardano/guards/source/apps/blockchain/cardano/contracts/aiken/artifacts/policy_vault.hash b/lazer/cardano/guards/source/apps/blockchain/cardano/contracts/aiken/artifacts/policy_vault.hash new file mode 100644 index 00000000..0aa7ba5a --- /dev/null +++ b/lazer/cardano/guards/source/apps/blockchain/cardano/contracts/aiken/artifacts/policy_vault.hash @@ -0,0 +1 @@ +d86fb4d585d62b644b02e9c41944b3c23c966b02c768d99aed4a85a7 diff --git a/lazer/cardano/guards/source/apps/blockchain/cardano/contracts/aiken/artifacts/policy_vault.plutus b/lazer/cardano/guards/source/apps/blockchain/cardano/contracts/aiken/artifacts/policy_vault.plutus new file mode 100644 index 00000000..0860ad6e --- /dev/null +++ b/lazer/cardano/guards/source/apps/blockchain/cardano/contracts/aiken/artifacts/policy_vault.plutus @@ -0,0 +1,5 @@ +{ + "type": "PlutusScriptV3", + "description": "Generated by Aiken", + "cborHex": "5909eb5909e801010029800aba2aba1aba0aab9faab9eaab9dab9a488888896600264653001300800198041804800cdc3a400530080024888966002600460106ea800e3300130093754007370e90004dc3a4009370e90034c020dd5002244444653001159800980298071baa0018cc004c048c03cdd5000c88c8cc00400400c896600200314a115980099b8f375c602c00200714a31330020023017001404480a2460266028602860286028003230133014301430143014301430143014301400198071baa00a91809980a180a180a180a180a180a000c8c04cc050c050c050c050c050c050c050c050c050006460266028602860286028602860286028602860286028602800323013301430143014301430143014301430143014301400191809980a180a180a180a180a000c8c04cc050006460266028602860286028602860286028003371090004dc7a450091809980a180a180a000c8c04cc050c0500052222222222222222980091198089bac300d3021375400400325980098029bad300e3020375400315980098029bad300b3020375400315980099b89375a601460406ea8004dd6980498101baa0018acc006600266e3cdd7180418101baa001375c601860406ea800694294501e456600330013004375c604660406ea800694294501e456600330013004375c600e60406ea800694294501e456600330013004375c600c60406ea800694294501e46600260086eb8c08cc090c090c090c090c090c090c090c090c090c090c090c090c080dd5000d28528a03c8a50407914a080f2294101e4528203c8a50407914a080f2294101e4896600266e3c004dd7180798109baa0028a51899b8f001375c600e60426ea800901f488cdc49bad300f302137540026eb4c028c084dd50012444464b3001301a01189919194c004dd69815000cc0a8c0ac0066054007375c605400491112cc00566002660366eb0c04cc0acdd500e000c4cc06cdd6180c98159baa0230018a5040a5159800acc00566002604260546ea8c058c0acdd500e4528c52820528a508cc00528528528a05240a5159800acc0066002601e6eb8c0b8c0acdd5001528528a0528acc0066002601e6eb8c048c0acdd5001528528a0528cc004c03cdd7180698159baa002a50a5140a514a0814a2941029456600266e3cdd7181718159baa002375c602460566ea80722b3001337126eb4c038c0acdd500e002456600266e24dd69817181798179817981798179817981798179817981798179817981798159baa01c337020086eb4c038c0acdd500e456600266e1cdd6980a98159baa0030048acc004cdc79bae3012302b37540066eb8c0b8c0acdd500e4566002660180386eb8c044c0acdd5001c56600260160071598009980400e001c4cc02407000e2941029452820528a5040a514a0814a2941029452820528a5040a514a0814a2941029452820528a5040a4302a00130290013024375403f159800980e808c4c8cc896600266e3cdd7181518139baa018375c601c604e6ea800a2b30013371e6eb8c038c09cdd50009bae302a3027375403115980099baf30123027375403066e95200033029302a3027375400497ae08acc004cc020060dd7180a98139baa0018acc004c01c00a2b3001330040180028acc004cc01406000a2b30015980099b8f375c6054604e6ea8004dd7181518139baa0028acc004cdc79bae3009302737540026eb8c024c09cdd5001456600266e3cdd7180518139baa001375c601e604e6ea800a2b30013371e6eb8c058c09cdd50009bae30133027375400513371e6eb8c054c09cdd50009bae300d3027375400514a0812a29410254528204a8a504095159800acc004cdc49bad3011302737540046eb4c048c09cdd5000c4cdc49bad3012302737540026eb4c040c09cdd50014528204a8acc0056600266e252000375a601e604e6ea80062b30013371290001bad30133027375400315980099b89375a601e604e6ea8004dd6980a98139baa0028acc004cdc49bad3012302737540046eb4c04cc09cdd5000c56600260186eb4c034c09cdd5000c6600260166eb8c040c09cdd5000d28528a04a8a50409514a0812a29410254528204a8a5040951337126eb4c03cc09cdd50009bad30103027375403114a0812a29410254528204a8a50409514a0812a29410254528204a8a50409514a0812a294102518140009814181480098121baa01f8acc004c06404629422b300130180118a508992cc004cc054dd6180a18129baa01600189980a9bac30133025375403a00314a08118dd7181398121baa01f4088811102220442259800998020011bae300d30253754003133004002375c6022604a6ea8006294102311640352259800980398081baa002899191919191919191919191919194c004dd61811000cdd718110074dd71811006cdd718110064c08802a6eb8c0880266eb8c0880226eb8c08801e6eb4c08801a6eb4c0880166eb4c0880126eb4c08800e6eb8c088009222222222222259800981800744c8cc8966002604a00515980098179baa003800c590304566002605000515980098179baa003800c5903045902d205a302c375400226644b300130250018acc004c0bcdd5006400a2c81822b300130280018acc004c0bcdd5006400a2c81822b300130240018acc004c0bcdd5006400a2c81822b300130230018acc004c0bcdd5006400a2c81822b30013370e9004000c566002605e6ea80320051640c11640b4816902d205a40b42646600200201e44b300100180fc4c8cc00c00cc0d0008dd71819000a060302c3754014605e0311640b4302200130210013020001301f001301e001301d001301c001301b001301a00130190013018001301700130160013011375400516403d3012006488966002601000b132323298009bad3018001980c800cdd7180c0012444b3001301c0028992cc004c03cc060dd5000c4c8c8ca60026eb8c07c0066eb8c07c00e6eb8c07c00922259800981180244cc03cc0880240422c8100603e002603c00260326ea80062c80b8c06c01a2c80c86030002602e00260246ea80362b3001300b00589919192cc004c06400a26600a60300062b3001300b301437540031323232323232323232323298009bae30230019bae302300b9bae302300a9bae30230099bae30230089bae30230079bad30230069bad30230059bad30230049bae30230039bad302300248888888888966002605e01901c8b20581811800981100098108009810000980f800980f000980e800980e000980d800980d000980a9baa0018b20268b202c301700130170013012375401b1598009803802c4c8c8cc896600260340070078b202e375a602e0026eb8c05c008c05c004c048dd5006c566002600c00b13232332259800980d001c01e2c80b8dd6980b8009bae301700230170013012375401b15980099b874802001626464b30013018002802c590151bae30160013012375401b164040808101020204040301130120054528200e180400098019baa0088a4d13656400401" +} diff --git a/lazer/cardano/guards/source/apps/blockchain/cardano/contracts/aiken/lib/guards_one/policy_vault.ak b/lazer/cardano/guards/source/apps/blockchain/cardano/contracts/aiken/lib/guards_one/policy_vault.ak new file mode 100644 index 00000000..4fd6d81a --- /dev/null +++ b/lazer/cardano/guards/source/apps/blockchain/cardano/contracts/aiken/lib/guards_one/policy_vault.ak @@ -0,0 +1,154 @@ +use aiken/collection/list +use aiken/crypto.{VerificationKeyHash} +use cardano/transaction.{Transaction} +use guards_one/types.{ + ExecutionIntent, ExecutionResult, PolicyDatum, PythWitness, +} + +// Scaffold policy module. +// The actual Aiken port should mirror the runtime checks currently implemented in +// `packages/cardano/src/policy-vault.ts` and the custody model in +// `docs/cardano-custody-model.md`. + +pub fn is_governance_authorized( + datum: PolicyDatum, + self: Transaction, + actor: VerificationKeyHash, +) -> Bool { + list.has(datum.governance_signers, actor) && list.has( + self.extra_signatories, + actor, + ) +} + +pub fn is_keeper_authorized( + datum: PolicyDatum, + self: Transaction, + keeper: VerificationKeyHash, +) -> Bool { + list.has(datum.keepers, keeper) && list.has(self.extra_signatories, keeper) +} + +pub fn has_intent_in_flight(datum: PolicyDatum) -> Bool { + when datum.current_intent_id is { + Some(_) -> True + None -> False + } +} + +pub fn is_route_approved(datum: PolicyDatum, route_id: ByteArray) -> Bool { + list.has(datum.approved_route_ids, route_id) +} + +pub fn has_live_witness(witness: PythWitness) -> Bool { + witness.pyth_policy_id != #"" && witness.pyth_state_reference != #"" && witness.signed_update_hex != #"" +} + +pub fn is_asset_approved(datum: PolicyDatum, asset_id: ByteArray) -> Bool { + asset_id == datum.primary_asset_id || asset_id == datum.stable_asset_id +} + +pub fn is_intent_shape_valid(intent: ExecutionIntent) -> Bool { + intent.max_sell_amount > 0 && intent.min_buy_amount > 0 && intent.created_at_us <= intent.expiry_us && intent.source_asset_id != intent.destination_asset_id && intent.intent_id != #"" && intent.vault_id != #"" && intent.route_id != #"" && intent.reason_hash != #"" +} + +pub fn is_intent_asset_pair_approved( + datum: PolicyDatum, + intent: ExecutionIntent, +) -> Bool { + is_asset_approved(datum, intent.source_asset_id) && is_asset_approved( + datum, + intent.destination_asset_id, + ) +} + +pub fn is_hot_bucket_intent_bounded( + datum: PolicyDatum, + intent: ExecutionIntent, +) -> Bool { + intent.max_sell_amount <= datum.hot_bucket_max_notional +} + +pub fn is_result_matching_intent( + intent: ExecutionIntent, + result: ExecutionResult, +) -> Bool { + result.intent_id == intent.intent_id && result.chain_id == intent.chain_id && result.source_asset_id == intent.source_asset_id && result.destination_asset_id == intent.destination_asset_id && result.route_id == intent.route_id +} + +pub fn is_result_in_window( + intent: ExecutionIntent, + result: ExecutionResult, +) -> Bool { + result.executed_at_us >= intent.created_at_us && result.executed_at_us <= intent.expiry_us +} + +pub fn is_result_within_bounds( + intent: ExecutionIntent, + result: ExecutionResult, +) -> Bool { + result.sold_amount >= 0 && result.bought_amount >= 0 && result.sold_amount <= intent.max_sell_amount && result.bought_amount >= intent.min_buy_amount && result.average_price > 0 && result.tx_hash != #"" +} + +pub fn is_hot_bucket_result_bounded( + datum: PolicyDatum, + result: ExecutionResult, +) -> Bool { + result.sold_amount <= datum.hot_bucket_max_notional +} + +pub fn authorize_execution( + datum: PolicyDatum, + witness: PythWitness, + keeper: VerificationKeyHash, + now_us: Int, + intent: ExecutionIntent, + self: Transaction, +) -> Bool { + // This stays bounded: it verifies keeper signatures, witness presence, cooldown, + // route/asset allowlists, and hot-bucket limits. It still does not parse the + // Pyth payload on-chain or verify output continuity against a produced next datum. + is_keeper_authorized(datum, self, keeper) && has_intent_in_flight(datum) == False && has_live_witness( + witness, + ) && witness.pyth_policy_id == datum.pyth_policy_id && now_us >= datum.last_transition_us && now_us - datum.last_transition_us >= datum.cooldown_us && intent.created_at_us == now_us && intent.vault_id == datum.vault_id && is_route_approved( + datum, + intent.route_id, + ) && is_intent_shape_valid(intent) && is_intent_asset_pair_approved( + datum, + intent, + ) && is_hot_bucket_intent_bounded(datum, intent) +} + +pub fn complete_execution( + datum: PolicyDatum, + intent: ExecutionIntent, + result: ExecutionResult, +) -> Bool { + // The scaffold now mirrors the pure completion invariants from the TypeScript + // simulator even before the tx-context plumbing is available. + datum.vault_id == intent.vault_id && result.vault_id == datum.vault_id && datum.current_intent_id == Some( + intent.intent_id, + ) && is_route_approved(datum, result.route_id) && is_intent_shape_valid( + intent, + ) && is_intent_asset_pair_approved(datum, intent) && is_hot_bucket_intent_bounded( + datum, + intent, + ) && is_result_matching_intent(intent, result) && is_result_in_window( + intent, + result, + ) && is_result_within_bounds(intent, result) && is_hot_bucket_result_bounded( + datum, + result, + ) +} + +pub fn emergency_withdraw( + datum: PolicyDatum, + actor: VerificationKeyHash, + self: Transaction, +) -> Bool { + // This branch is intentionally explicit: governance can always recover funds + // with an extra signatory, even while UpdatePolicy/Resume stay closed until + // output continuity checks are wired into the validator. + is_governance_authorized(datum, self, actor) +} diff --git a/lazer/cardano/guards/source/apps/blockchain/cardano/contracts/aiken/lib/guards_one/types.ak b/lazer/cardano/guards/source/apps/blockchain/cardano/contracts/aiken/lib/guards_one/types.ak new file mode 100644 index 00000000..8c553b1a --- /dev/null +++ b/lazer/cardano/guards/source/apps/blockchain/cardano/contracts/aiken/lib/guards_one/types.ak @@ -0,0 +1,99 @@ +use aiken/crypto.{VerificationKeyHash} + +// Bounded Aiken scaffold for guards.one. +// This file intentionally models the data shapes first so the future validator port +// can stay aligned with the current TypeScript simulator. + +pub type RiskStage { + Normal + Watch + PartialDerisk + FullExit + Frozen +} + +pub type ExecutionKind { + DeriskSwap + ReentrySwap +} + +pub type PolicyDatum { + vault_id: ByteArray, + pyth_policy_id: ByteArray, + stage: RiskStage, + last_transition_us: Int, + governance_signers: List, + keepers: List, + approved_route_ids: List, + stable_asset_id: ByteArray, + primary_asset_id: ByteArray, + current_intent_id: Option, + hot_bucket_max_notional: Int, + max_stale_us: Int, + max_confidence_bps: Int, + cooldown_us: Int, +} + +pub type PolicyRedeemer { + AuthorizeExecution { + witness: PythWitness, + keeper: VerificationKeyHash, + now_us: Int, + intent: ExecutionIntent, + } + + CompleteExecution { intent: ExecutionIntent, result: ExecutionResult } + + UpdatePolicy { actor: VerificationKeyHash, now_us: Int } + + Resume { actor: VerificationKeyHash, now_us: Int } + + EmergencyWithdraw { actor: VerificationKeyHash } +} + +pub type PythWitness { + pyth_policy_id: ByteArray, + pyth_state_reference: ByteArray, + signed_update_hex: ByteArray, +} + +pub type ExecutionIntent { + intent_id: ByteArray, + vault_id: ByteArray, + chain_id: ByteArray, + kind: ExecutionKind, + stage: RiskStage, + source_asset_id: ByteArray, + destination_asset_id: ByteArray, + route_id: ByteArray, + max_sell_amount: Int, + min_buy_amount: Int, + expiry_us: Int, + created_at_us: Int, + reason_hash: ByteArray, + snapshot_ids: List, +} + +pub type ExecutionResult { + intent_id: ByteArray, + vault_id: ByteArray, + chain_id: ByteArray, + source_asset_id: ByteArray, + destination_asset_id: ByteArray, + sold_amount: Int, + bought_amount: Int, + average_price: Int, + route_id: ByteArray, + executed_at_us: Int, + tx_hash: ByteArray, +} + +pub type PolicyCheckFailure { + KeeperNotAuthorized + PythPolicyMismatch + IntentAlreadyInFlight + RouteNotApproved + AssetNotApproved + CooldownActive + FreezeRequired +} diff --git a/lazer/cardano/guards/source/apps/blockchain/cardano/contracts/aiken/plutus.json b/lazer/cardano/guards/source/apps/blockchain/cardano/contracts/aiken/plutus.json new file mode 100644 index 00000000..ed3ecabe --- /dev/null +++ b/lazer/cardano/guards/source/apps/blockchain/cardano/contracts/aiken/plutus.json @@ -0,0 +1,439 @@ +{ + "preamble": { + "title": "solx-ar/guards-one-cardano-aiken", + "description": "Bounded Aiken scaffold for the guards.one PolicyVault and hot-bucket model", + "version": "0.0.0", + "plutusVersion": "v3", + "compiler": { + "name": "Aiken", + "version": "v1.1.21+unknown" + }, + "license": "Apache-2.0" + }, + "validators": [ + { + "title": "policy_vault.policy_vault.spend", + "datum": { + "title": "datum", + "schema": { + "$ref": "#/definitions/guards_one~1types~1PolicyDatum" + } + }, + "redeemer": { + "title": "redeemer", + "schema": { + "$ref": "#/definitions/guards_one~1types~1PolicyRedeemer" + } + }, + "compiledCode": "5909e801010029800aba2aba1aba0aab9faab9eaab9dab9a488888896600264653001300800198041804800cdc3a400530080024888966002600460106ea800e3300130093754007370e90004dc3a4009370e90034c020dd5002244444653001159800980298071baa0018cc004c048c03cdd5000c88c8cc00400400c896600200314a115980099b8f375c602c00200714a31330020023017001404480a2460266028602860286028003230133014301430143014301430143014301400198071baa00a91809980a180a180a180a180a180a000c8c04cc050c050c050c050c050c050c050c050c050006460266028602860286028602860286028602860286028602800323013301430143014301430143014301430143014301400191809980a180a180a180a180a000c8c04cc050006460266028602860286028602860286028003371090004dc7a450091809980a180a180a000c8c04cc050c0500052222222222222222980091198089bac300d3021375400400325980098029bad300e3020375400315980098029bad300b3020375400315980099b89375a601460406ea8004dd6980498101baa0018acc006600266e3cdd7180418101baa001375c601860406ea800694294501e456600330013004375c604660406ea800694294501e456600330013004375c600e60406ea800694294501e456600330013004375c600c60406ea800694294501e46600260086eb8c08cc090c090c090c090c090c090c090c090c090c090c090c090c080dd5000d28528a03c8a50407914a080f2294101e4528203c8a50407914a080f2294101e4896600266e3c004dd7180798109baa0028a51899b8f001375c600e60426ea800901f488cdc49bad300f302137540026eb4c028c084dd50012444464b3001301a01189919194c004dd69815000cc0a8c0ac0066054007375c605400491112cc00566002660366eb0c04cc0acdd500e000c4cc06cdd6180c98159baa0230018a5040a5159800acc00566002604260546ea8c058c0acdd500e4528c52820528a508cc00528528528a05240a5159800acc0066002601e6eb8c0b8c0acdd5001528528a0528acc0066002601e6eb8c048c0acdd5001528528a0528cc004c03cdd7180698159baa002a50a5140a514a0814a2941029456600266e3cdd7181718159baa002375c602460566ea80722b3001337126eb4c038c0acdd500e002456600266e24dd69817181798179817981798179817981798179817981798179817981798159baa01c337020086eb4c038c0acdd500e456600266e1cdd6980a98159baa0030048acc004cdc79bae3012302b37540066eb8c0b8c0acdd500e4566002660180386eb8c044c0acdd5001c56600260160071598009980400e001c4cc02407000e2941029452820528a5040a514a0814a2941029452820528a5040a514a0814a2941029452820528a5040a4302a00130290013024375403f159800980e808c4c8cc896600266e3cdd7181518139baa018375c601c604e6ea800a2b30013371e6eb8c038c09cdd50009bae302a3027375403115980099baf30123027375403066e95200033029302a3027375400497ae08acc004cc020060dd7180a98139baa0018acc004c01c00a2b3001330040180028acc004cc01406000a2b30015980099b8f375c6054604e6ea8004dd7181518139baa0028acc004cdc79bae3009302737540026eb8c024c09cdd5001456600266e3cdd7180518139baa001375c601e604e6ea800a2b30013371e6eb8c058c09cdd50009bae30133027375400513371e6eb8c054c09cdd50009bae300d3027375400514a0812a29410254528204a8a504095159800acc004cdc49bad3011302737540046eb4c048c09cdd5000c4cdc49bad3012302737540026eb4c040c09cdd50014528204a8acc0056600266e252000375a601e604e6ea80062b30013371290001bad30133027375400315980099b89375a601e604e6ea8004dd6980a98139baa0028acc004cdc49bad3012302737540046eb4c04cc09cdd5000c56600260186eb4c034c09cdd5000c6600260166eb8c040c09cdd5000d28528a04a8a50409514a0812a29410254528204a8a5040951337126eb4c03cc09cdd50009bad30103027375403114a0812a29410254528204a8a50409514a0812a29410254528204a8a50409514a0812a294102518140009814181480098121baa01f8acc004c06404629422b300130180118a508992cc004cc054dd6180a18129baa01600189980a9bac30133025375403a00314a08118dd7181398121baa01f4088811102220442259800998020011bae300d30253754003133004002375c6022604a6ea8006294102311640352259800980398081baa002899191919191919191919191919194c004dd61811000cdd718110074dd71811006cdd718110064c08802a6eb8c0880266eb8c0880226eb8c08801e6eb4c08801a6eb4c0880166eb4c0880126eb4c08800e6eb8c088009222222222222259800981800744c8cc8966002604a00515980098179baa003800c590304566002605000515980098179baa003800c5903045902d205a302c375400226644b300130250018acc004c0bcdd5006400a2c81822b300130280018acc004c0bcdd5006400a2c81822b300130240018acc004c0bcdd5006400a2c81822b300130230018acc004c0bcdd5006400a2c81822b30013370e9004000c566002605e6ea80320051640c11640b4816902d205a40b42646600200201e44b300100180fc4c8cc00c00cc0d0008dd71819000a060302c3754014605e0311640b4302200130210013020001301f001301e001301d001301c001301b001301a00130190013018001301700130160013011375400516403d3012006488966002601000b132323298009bad3018001980c800cdd7180c0012444b3001301c0028992cc004c03cc060dd5000c4c8c8ca60026eb8c07c0066eb8c07c00e6eb8c07c00922259800981180244cc03cc0880240422c8100603e002603c00260326ea80062c80b8c06c01a2c80c86030002602e00260246ea80362b3001300b00589919192cc004c06400a26600a60300062b3001300b301437540031323232323232323232323298009bae30230019bae302300b9bae302300a9bae30230099bae30230089bae30230079bad30230069bad30230059bad30230049bae30230039bad302300248888888888966002605e01901c8b20581811800981100098108009810000980f800980f000980e800980e000980d800980d000980a9baa0018b20268b202c301700130170013012375401b1598009803802c4c8c8cc896600260340070078b202e375a602e0026eb8c05c008c05c004c048dd5006c566002600c00b13232332259800980d001c01e2c80b8dd6980b8009bae301700230170013012375401b15980099b874802001626464b30013018002802c590151bae30160013012375401b164040808101020204040301130120054528200e180400098019baa0088a4d13656400401", + "hash": "d86fb4d585d62b644b02e9c41944b3c23c966b02c768d99aed4a85a7" + }, + { + "title": "policy_vault.policy_vault.else", + "redeemer": { + "schema": {} + }, + "compiledCode": "5909e801010029800aba2aba1aba0aab9faab9eaab9dab9a488888896600264653001300800198041804800cdc3a400530080024888966002600460106ea800e3300130093754007370e90004dc3a4009370e90034c020dd5002244444653001159800980298071baa0018cc004c048c03cdd5000c88c8cc00400400c896600200314a115980099b8f375c602c00200714a31330020023017001404480a2460266028602860286028003230133014301430143014301430143014301400198071baa00a91809980a180a180a180a180a180a000c8c04cc050c050c050c050c050c050c050c050c050006460266028602860286028602860286028602860286028602800323013301430143014301430143014301430143014301400191809980a180a180a180a180a000c8c04cc050006460266028602860286028602860286028003371090004dc7a450091809980a180a180a000c8c04cc050c0500052222222222222222980091198089bac300d3021375400400325980098029bad300e3020375400315980098029bad300b3020375400315980099b89375a601460406ea8004dd6980498101baa0018acc006600266e3cdd7180418101baa001375c601860406ea800694294501e456600330013004375c604660406ea800694294501e456600330013004375c600e60406ea800694294501e456600330013004375c600c60406ea800694294501e46600260086eb8c08cc090c090c090c090c090c090c090c090c090c090c090c090c080dd5000d28528a03c8a50407914a080f2294101e4528203c8a50407914a080f2294101e4896600266e3c004dd7180798109baa0028a51899b8f001375c600e60426ea800901f488cdc49bad300f302137540026eb4c028c084dd50012444464b3001301a01189919194c004dd69815000cc0a8c0ac0066054007375c605400491112cc00566002660366eb0c04cc0acdd500e000c4cc06cdd6180c98159baa0230018a5040a5159800acc00566002604260546ea8c058c0acdd500e4528c52820528a508cc00528528528a05240a5159800acc0066002601e6eb8c0b8c0acdd5001528528a0528acc0066002601e6eb8c048c0acdd5001528528a0528cc004c03cdd7180698159baa002a50a5140a514a0814a2941029456600266e3cdd7181718159baa002375c602460566ea80722b3001337126eb4c038c0acdd500e002456600266e24dd69817181798179817981798179817981798179817981798179817981798159baa01c337020086eb4c038c0acdd500e456600266e1cdd6980a98159baa0030048acc004cdc79bae3012302b37540066eb8c0b8c0acdd500e4566002660180386eb8c044c0acdd5001c56600260160071598009980400e001c4cc02407000e2941029452820528a5040a514a0814a2941029452820528a5040a514a0814a2941029452820528a5040a4302a00130290013024375403f159800980e808c4c8cc896600266e3cdd7181518139baa018375c601c604e6ea800a2b30013371e6eb8c038c09cdd50009bae302a3027375403115980099baf30123027375403066e95200033029302a3027375400497ae08acc004cc020060dd7180a98139baa0018acc004c01c00a2b3001330040180028acc004cc01406000a2b30015980099b8f375c6054604e6ea8004dd7181518139baa0028acc004cdc79bae3009302737540026eb8c024c09cdd5001456600266e3cdd7180518139baa001375c601e604e6ea800a2b30013371e6eb8c058c09cdd50009bae30133027375400513371e6eb8c054c09cdd50009bae300d3027375400514a0812a29410254528204a8a504095159800acc004cdc49bad3011302737540046eb4c048c09cdd5000c4cdc49bad3012302737540026eb4c040c09cdd50014528204a8acc0056600266e252000375a601e604e6ea80062b30013371290001bad30133027375400315980099b89375a601e604e6ea8004dd6980a98139baa0028acc004cdc49bad3012302737540046eb4c04cc09cdd5000c56600260186eb4c034c09cdd5000c6600260166eb8c040c09cdd5000d28528a04a8a50409514a0812a29410254528204a8a5040951337126eb4c03cc09cdd50009bad30103027375403114a0812a29410254528204a8a50409514a0812a29410254528204a8a50409514a0812a294102518140009814181480098121baa01f8acc004c06404629422b300130180118a508992cc004cc054dd6180a18129baa01600189980a9bac30133025375403a00314a08118dd7181398121baa01f4088811102220442259800998020011bae300d30253754003133004002375c6022604a6ea8006294102311640352259800980398081baa002899191919191919191919191919194c004dd61811000cdd718110074dd71811006cdd718110064c08802a6eb8c0880266eb8c0880226eb8c08801e6eb4c08801a6eb4c0880166eb4c0880126eb4c08800e6eb8c088009222222222222259800981800744c8cc8966002604a00515980098179baa003800c590304566002605000515980098179baa003800c5903045902d205a302c375400226644b300130250018acc004c0bcdd5006400a2c81822b300130280018acc004c0bcdd5006400a2c81822b300130240018acc004c0bcdd5006400a2c81822b300130230018acc004c0bcdd5006400a2c81822b30013370e9004000c566002605e6ea80320051640c11640b4816902d205a40b42646600200201e44b300100180fc4c8cc00c00cc0d0008dd71819000a060302c3754014605e0311640b4302200130210013020001301f001301e001301d001301c001301b001301a00130190013018001301700130160013011375400516403d3012006488966002601000b132323298009bad3018001980c800cdd7180c0012444b3001301c0028992cc004c03cc060dd5000c4c8c8ca60026eb8c07c0066eb8c07c00e6eb8c07c00922259800981180244cc03cc0880240422c8100603e002603c00260326ea80062c80b8c06c01a2c80c86030002602e00260246ea80362b3001300b00589919192cc004c06400a26600a60300062b3001300b301437540031323232323232323232323298009bae30230019bae302300b9bae302300a9bae30230099bae30230089bae30230079bad30230069bad30230059bad30230049bae30230039bad302300248888888888966002605e01901c8b20581811800981100098108009810000980f800980f000980e800980e000980d800980d000980a9baa0018b20268b202c301700130170013012375401b1598009803802c4c8c8cc896600260340070078b202e375a602e0026eb8c05c008c05c004c048dd5006c566002600c00b13232332259800980d001c01e2c80b8dd6980b8009bae301700230170013012375401b15980099b874802001626464b30013018002802c590151bae30160013012375401b164040808101020204040301130120054528200e180400098019baa0088a4d13656400401", + "hash": "d86fb4d585d62b644b02e9c41944b3c23c966b02c768d99aed4a85a7" + } + ], + "definitions": { + "ByteArray": { + "dataType": "bytes" + }, + "Int": { + "dataType": "integer" + }, + "List": { + "dataType": "list", + "items": { + "$ref": "#/definitions/ByteArray" + } + }, + "List": { + "dataType": "list", + "items": { + "$ref": "#/definitions/aiken~1crypto~1VerificationKeyHash" + } + }, + "Option": { + "title": "Option", + "anyOf": [ + { + "title": "Some", + "description": "An optional value.", + "dataType": "constructor", + "index": 0, + "fields": [ + { + "$ref": "#/definitions/ByteArray" + } + ] + }, + { + "title": "None", + "description": "Nothing.", + "dataType": "constructor", + "index": 1, + "fields": [] + } + ] + }, + "aiken/crypto/VerificationKeyHash": { + "title": "VerificationKeyHash", + "dataType": "bytes" + }, + "guards_one/types/ExecutionIntent": { + "title": "ExecutionIntent", + "anyOf": [ + { + "title": "ExecutionIntent", + "dataType": "constructor", + "index": 0, + "fields": [ + { + "title": "intent_id", + "$ref": "#/definitions/ByteArray" + }, + { + "title": "vault_id", + "$ref": "#/definitions/ByteArray" + }, + { + "title": "chain_id", + "$ref": "#/definitions/ByteArray" + }, + { + "title": "kind", + "$ref": "#/definitions/guards_one~1types~1ExecutionKind" + }, + { + "title": "stage", + "$ref": "#/definitions/guards_one~1types~1RiskStage" + }, + { + "title": "source_asset_id", + "$ref": "#/definitions/ByteArray" + }, + { + "title": "destination_asset_id", + "$ref": "#/definitions/ByteArray" + }, + { + "title": "route_id", + "$ref": "#/definitions/ByteArray" + }, + { + "title": "max_sell_amount", + "$ref": "#/definitions/Int" + }, + { + "title": "min_buy_amount", + "$ref": "#/definitions/Int" + }, + { + "title": "expiry_us", + "$ref": "#/definitions/Int" + }, + { + "title": "created_at_us", + "$ref": "#/definitions/Int" + }, + { + "title": "reason_hash", + "$ref": "#/definitions/ByteArray" + }, + { + "title": "snapshot_ids", + "$ref": "#/definitions/List" + } + ] + } + ] + }, + "guards_one/types/ExecutionKind": { + "title": "ExecutionKind", + "anyOf": [ + { + "title": "DeriskSwap", + "dataType": "constructor", + "index": 0, + "fields": [] + }, + { + "title": "ReentrySwap", + "dataType": "constructor", + "index": 1, + "fields": [] + } + ] + }, + "guards_one/types/ExecutionResult": { + "title": "ExecutionResult", + "anyOf": [ + { + "title": "ExecutionResult", + "dataType": "constructor", + "index": 0, + "fields": [ + { + "title": "intent_id", + "$ref": "#/definitions/ByteArray" + }, + { + "title": "vault_id", + "$ref": "#/definitions/ByteArray" + }, + { + "title": "chain_id", + "$ref": "#/definitions/ByteArray" + }, + { + "title": "source_asset_id", + "$ref": "#/definitions/ByteArray" + }, + { + "title": "destination_asset_id", + "$ref": "#/definitions/ByteArray" + }, + { + "title": "sold_amount", + "$ref": "#/definitions/Int" + }, + { + "title": "bought_amount", + "$ref": "#/definitions/Int" + }, + { + "title": "average_price", + "$ref": "#/definitions/Int" + }, + { + "title": "route_id", + "$ref": "#/definitions/ByteArray" + }, + { + "title": "executed_at_us", + "$ref": "#/definitions/Int" + }, + { + "title": "tx_hash", + "$ref": "#/definitions/ByteArray" + } + ] + } + ] + }, + "guards_one/types/PolicyDatum": { + "title": "PolicyDatum", + "anyOf": [ + { + "title": "PolicyDatum", + "dataType": "constructor", + "index": 0, + "fields": [ + { + "title": "vault_id", + "$ref": "#/definitions/ByteArray" + }, + { + "title": "pyth_policy_id", + "$ref": "#/definitions/ByteArray" + }, + { + "title": "stage", + "$ref": "#/definitions/guards_one~1types~1RiskStage" + }, + { + "title": "last_transition_us", + "$ref": "#/definitions/Int" + }, + { + "title": "governance_signers", + "$ref": "#/definitions/List" + }, + { + "title": "keepers", + "$ref": "#/definitions/List" + }, + { + "title": "approved_route_ids", + "$ref": "#/definitions/List" + }, + { + "title": "stable_asset_id", + "$ref": "#/definitions/ByteArray" + }, + { + "title": "primary_asset_id", + "$ref": "#/definitions/ByteArray" + }, + { + "title": "current_intent_id", + "$ref": "#/definitions/Option" + }, + { + "title": "hot_bucket_max_notional", + "$ref": "#/definitions/Int" + }, + { + "title": "max_stale_us", + "$ref": "#/definitions/Int" + }, + { + "title": "max_confidence_bps", + "$ref": "#/definitions/Int" + }, + { + "title": "cooldown_us", + "$ref": "#/definitions/Int" + } + ] + } + ] + }, + "guards_one/types/PolicyRedeemer": { + "title": "PolicyRedeemer", + "anyOf": [ + { + "title": "AuthorizeExecution", + "dataType": "constructor", + "index": 0, + "fields": [ + { + "title": "witness", + "$ref": "#/definitions/guards_one~1types~1PythWitness" + }, + { + "title": "keeper", + "$ref": "#/definitions/aiken~1crypto~1VerificationKeyHash" + }, + { + "title": "now_us", + "$ref": "#/definitions/Int" + }, + { + "title": "intent", + "$ref": "#/definitions/guards_one~1types~1ExecutionIntent" + } + ] + }, + { + "title": "CompleteExecution", + "dataType": "constructor", + "index": 1, + "fields": [ + { + "title": "intent", + "$ref": "#/definitions/guards_one~1types~1ExecutionIntent" + }, + { + "title": "result", + "$ref": "#/definitions/guards_one~1types~1ExecutionResult" + } + ] + }, + { + "title": "UpdatePolicy", + "dataType": "constructor", + "index": 2, + "fields": [ + { + "title": "actor", + "$ref": "#/definitions/aiken~1crypto~1VerificationKeyHash" + }, + { + "title": "now_us", + "$ref": "#/definitions/Int" + } + ] + }, + { + "title": "Resume", + "dataType": "constructor", + "index": 3, + "fields": [ + { + "title": "actor", + "$ref": "#/definitions/aiken~1crypto~1VerificationKeyHash" + }, + { + "title": "now_us", + "$ref": "#/definitions/Int" + } + ] + }, + { + "title": "EmergencyWithdraw", + "dataType": "constructor", + "index": 4, + "fields": [ + { + "title": "actor", + "$ref": "#/definitions/aiken~1crypto~1VerificationKeyHash" + } + ] + } + ] + }, + "guards_one/types/PythWitness": { + "title": "PythWitness", + "anyOf": [ + { + "title": "PythWitness", + "dataType": "constructor", + "index": 0, + "fields": [ + { + "title": "pyth_policy_id", + "$ref": "#/definitions/ByteArray" + }, + { + "title": "pyth_state_reference", + "$ref": "#/definitions/ByteArray" + }, + { + "title": "signed_update_hex", + "$ref": "#/definitions/ByteArray" + } + ] + } + ] + }, + "guards_one/types/RiskStage": { + "title": "RiskStage", + "anyOf": [ + { + "title": "Normal", + "dataType": "constructor", + "index": 0, + "fields": [] + }, + { + "title": "Watch", + "dataType": "constructor", + "index": 1, + "fields": [] + }, + { + "title": "PartialDerisk", + "dataType": "constructor", + "index": 2, + "fields": [] + }, + { + "title": "FullExit", + "dataType": "constructor", + "index": 3, + "fields": [] + }, + { + "title": "Frozen", + "dataType": "constructor", + "index": 4, + "fields": [] + } + ] + } + } +} \ No newline at end of file diff --git a/lazer/cardano/guards/source/apps/blockchain/cardano/contracts/aiken/scripts/build.sh b/lazer/cardano/guards/source/apps/blockchain/cardano/contracts/aiken/scripts/build.sh new file mode 100755 index 00000000..56f61406 --- /dev/null +++ b/lazer/cardano/guards/source/apps/blockchain/cardano/contracts/aiken/scripts/build.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +ARTIFACTS_DIR="${PROJECT_DIR}/artifacts" +MODULE_NAME="${POLICY_VAULT_MODULE:-policy_vault}" +VALIDATOR_NAME="${POLICY_VAULT_VALIDATOR:-policy_vault}" +HASH_FILE="${POLICY_VAULT_HASH_FILE:-${ARTIFACTS_DIR}/policy_vault.hash}" + +"${SCRIPT_DIR}/doctor.sh" build + +cd "$PROJECT_DIR" +printf 'Running aiken build in %s\n' "$PROJECT_DIR" +aiken build +mkdir -p "$ARTIFACTS_DIR" +aiken blueprint convert \ + --module "$MODULE_NAME" \ + --validator "$VALIDATOR_NAME" \ + . > "${ARTIFACTS_DIR}/policy_vault.plutus" +aiken blueprint hash \ + --module "$MODULE_NAME" \ + --validator "$VALIDATOR_NAME" \ + . > "$HASH_FILE" + +printf '\nBlueprint ready at %s/plutus.json\n' "$PROJECT_DIR" +printf 'Cardano CLI artifact exported to %s/policy_vault.plutus\n' "$ARTIFACTS_DIR" +printf 'Script hash exported to %s\n' "$HASH_FILE" +printf 'Next: run derive-address.sh to compute the preprod address.\n' diff --git a/lazer/cardano/guards/source/apps/blockchain/cardano/contracts/aiken/scripts/derive-address.sh b/lazer/cardano/guards/source/apps/blockchain/cardano/contracts/aiken/scripts/derive-address.sh new file mode 100755 index 00000000..1950c0c9 --- /dev/null +++ b/lazer/cardano/guards/source/apps/blockchain/cardano/contracts/aiken/scripts/derive-address.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +ARTIFACTS_DIR="${PROJECT_DIR}/artifacts" +MODULE_NAME="${POLICY_VAULT_MODULE:-policy_vault}" +VALIDATOR_NAME="${POLICY_VAULT_VALIDATOR:-policy_vault}" +ADDRESS_FILE="${POLICY_VAULT_ADDRESS_FILE:-${ARTIFACTS_DIR}/policy_vault.addr}" +HASH_FILE="${POLICY_VAULT_HASH_FILE:-${ARTIFACTS_DIR}/policy_vault.hash}" + +mkdir -p "$ARTIFACTS_DIR" + +"${SCRIPT_DIR}/doctor.sh" address + +cd "$PROJECT_DIR" +aiken blueprint address \ + --module "$MODULE_NAME" \ + --validator "$VALIDATOR_NAME" \ + . > "$ADDRESS_FILE" + +aiken blueprint hash \ + --module "$MODULE_NAME" \ + --validator "$VALIDATOR_NAME" \ + . > "$HASH_FILE" + +printf 'PolicyVault address written to %s\n' "$ADDRESS_FILE" +printf 'PolicyVault hash written to %s\n' "$HASH_FILE" diff --git a/lazer/cardano/guards/source/apps/blockchain/cardano/contracts/aiken/scripts/doctor.sh b/lazer/cardano/guards/source/apps/blockchain/cardano/contracts/aiken/scripts/doctor.sh new file mode 100755 index 00000000..9c99ccff --- /dev/null +++ b/lazer/cardano/guards/source/apps/blockchain/cardano/contracts/aiken/scripts/doctor.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" + +missing=0 +mode="${1:-full}" + +check_binary() { + local name="$1" + if command -v "$name" >/dev/null 2>&1; then + printf 'OK %s -> %s\n' "$name" "$(command -v "$name")" + else + printf 'MISS %s is not installed or not on PATH\n' "$name" >&2 + missing=1 + fi +} + +printf 'guards.one Cardano contract doctor\n' +printf 'project: %s\n' "$PROJECT_DIR" + +check_binary aiken + +printf '\nEnvironment\n' +printf 'CARDANO_NETWORK=%s\n' "${CARDANO_NETWORK:-preprod}" +printf 'CARDANO_TESTNET_MAGIC=%s\n' "${CARDANO_TESTNET_MAGIC:-1}" +printf 'PYTH_PREPROD_POLICY_ID=%s\n' "${PYTH_PREPROD_POLICY_ID:-unset}" + +if [[ $missing -ne 0 ]]; then + printf '\nToolchain incomplete for mode=%s.\n' "$mode" >&2 + exit 1 +fi + +printf '\nToolchain looks usable for Aiken blueprint build/address tasks.\n' diff --git a/lazer/cardano/guards/source/apps/blockchain/cardano/contracts/aiken/test/policy_vault.ak b/lazer/cardano/guards/source/apps/blockchain/cardano/contracts/aiken/test/policy_vault.ak new file mode 100644 index 00000000..3c5b1a6b --- /dev/null +++ b/lazer/cardano/guards/source/apps/blockchain/cardano/contracts/aiken/test/policy_vault.ak @@ -0,0 +1,7 @@ +// Minimal executable test so the Aiken project has a valid test module. +// The full validator matrix remains documented in README/docs until the +// tx-context-heavy scenarios are encoded here. + +test policy_vault_smoke() { + True +} diff --git a/lazer/cardano/guards/source/apps/blockchain/cardano/contracts/aiken/validators/policy_vault.ak b/lazer/cardano/guards/source/apps/blockchain/cardano/contracts/aiken/validators/policy_vault.ak new file mode 100644 index 00000000..46af0ccc --- /dev/null +++ b/lazer/cardano/guards/source/apps/blockchain/cardano/contracts/aiken/validators/policy_vault.ak @@ -0,0 +1,41 @@ +use cardano/script_context.{ScriptContext} +use cardano/transaction.{OutputReference, Transaction} +use guards_one/policy_vault as policy +use guards_one/types.{ + AuthorizeExecution, CompleteExecution, EmergencyWithdraw, PolicyDatum, + PolicyRedeemer, Resume, UpdatePolicy, +} + +// Spend validator for the bounded PolicyVault state machine. +// Authorize/complete remain fail-closed until the validator enforces +// continuing outputs, chain-derived time, and real on-chain Pyth verification. +validator policy_vault { + spend( + datum: Option, + redeemer: PolicyRedeemer, + _own_ref: OutputReference, + self: Transaction, + ) { + expect Some(datum) = datum + + when redeemer is { + AuthorizeExecution { .. } -> + False + + CompleteExecution { .. } -> + False + + // These paths still stay closed until the validator compares consumed and + // recreated outputs. Emergency withdraw is intentionally recoverable by + // governance signers. + UpdatePolicy { .. } -> False + Resume { .. } -> False + EmergencyWithdraw { actor } -> + policy.emergency_withdraw(datum, actor, self) + } + } + + else(_ctx: ScriptContext) { + False + } +} diff --git a/lazer/cardano/guards/source/apps/blockchain/cardano/offchain/README.md b/lazer/cardano/guards/source/apps/blockchain/cardano/offchain/README.md new file mode 100644 index 00000000..be251a8b --- /dev/null +++ b/lazer/cardano/guards/source/apps/blockchain/cardano/offchain/README.md @@ -0,0 +1,30 @@ +# Cardano Offchain + +This directory now holds the live Cardano off-chain Pyth wiring and the first execution-facing modules that the team should extend. + +Current source of truth in this directory: + +- `env.ts`: Cardano/Pyth runtime env loading +- `collector.ts`: in-memory snapshot collector plus live `PythLazer` signed-update fetcher +- `fixtures.ts`: demo witness + primary feed request builder +- `scripts/fetch-live.ts`: local CLI to fetch a real signed update and snapshot from Pyth + +Still pending migration into this directory: + +- `apps/backend/src/keeper.ts` +- `apps/backend/src/swap-venue.ts` +- `apps/backend/src/protocol-fee.ts` +- `apps/backend/src/dexhunter-live.ts` +- `apps/backend/src/dexhunter-keeper.ts` + +Quick check: + +```bash +pnpm pyth:fetch-live +``` + +That command uses `.env`, fetches a real signed update from Pyth Pro/Lazer, and prints the snapshot plus the Cardano witness envelope (`pythPolicyId`, `pythStateReference`, `signedUpdateHexLength`). + +Operational guide: + +- [`docs/preprod-vault-bootstrap.md`](../../../../docs/preprod-vault-bootstrap.md) diff --git a/lazer/cardano/guards/source/apps/blockchain/cardano/offchain/collector.ts b/lazer/cardano/guards/source/apps/blockchain/cardano/offchain/collector.ts new file mode 100644 index 00000000..737de66e --- /dev/null +++ b/lazer/cardano/guards/source/apps/blockchain/cardano/offchain/collector.ts @@ -0,0 +1,276 @@ +import { PythLazerClient, type Channel } from "@pythnetwork/pyth-lazer-sdk"; +import type { OracleSnapshot } from "@anaconda/core"; +import type { CardanoPythWitness } from "@anaconda/cardano"; +import { runtimeEnv } from "./env.js"; +import type { + PythLazerClientLike, + PythLiveCollectorConfig, + PythLiveFeedRequest, + PythSignedUpdateResult, + PythSymbolMatch, + SnapshotAuditSink, +} from "./types.js"; + +export class PythLiveCollectorError extends Error { + constructor( + readonly code: string, + message: string, + ) { + super(message); + } +} + +function normalizeSymbol(value: string): string { + return value.replace(/[^a-z0-9]/gi, "").toLowerCase(); +} + +function parseRequiredNumber( + value: number | string | undefined, + field: string, +): number { + const parsed = typeof value === "string" ? Number(value) : value; + if (!Number.isFinite(parsed)) { + throw new PythLiveCollectorError( + "PYTH_FIELD_MISSING", + `Pyth update is missing numeric field ${field}`, + ); + } + + return parsed as number; +} + +function normalizeTimestampUs(value: number): number { + if (value >= 1_000_000_000_000_000) { + return Math.trunc(value); + } + + if (value >= 1_000_000_000_000) { + return Math.trunc(value * 1_000); + } + + if (value >= 1_000_000_000) { + return Math.trunc(value * 1_000_000); + } + + return Math.trunc(value); +} + +function buildSnapshotId(assetId: string, observedAtUs: number): string { + return `snapshot:${assetId}:${observedAtUs}`; +} + +function normalizeSignedUpdateHex(value: string): string { + if (value.startsWith("0x") || value.startsWith("0X")) { + return value; + } + + return `0x${value}`; +} + +function createWitness( + signedUpdateHex: string, + config: Pick, +): CardanoPythWitness { + return { + pythPolicyId: config.pythPolicyId, + pythStateReference: config.pythStateReference, + signedUpdateHex, + }; +} + +function selectBestSymbolMatch( + matches: PythSymbolMatch[], + request: PythLiveFeedRequest, +): PythSymbolMatch | undefined { + const query = normalizeSymbol(request.symbolQuery ?? request.symbol); + const exact = matches.find((candidate) => { + const fields = [candidate.symbol, candidate.name, candidate.description].map(normalizeSymbol); + return fields.includes(query); + }); + if (exact) { + return exact; + } + + return matches.find((candidate) => { + const candidateSymbol = normalizeSymbol(candidate.symbol); + return candidateSymbol.endsWith(query) || candidateSymbol.includes(query); + }); +} + +export class PythCollector { + private readonly snapshots = new Map(); + + constructor(protected readonly auditSink?: SnapshotAuditSink) {} + + publish(snapshot: OracleSnapshot) { + this.snapshots.set(snapshot.assetId, snapshot); + this.auditSink?.recordSnapshot?.(snapshot); + this.auditSink?.recordEvent?.({ + eventId: `snapshot:${snapshot.snapshotId}`, + category: "snapshot", + payload: { + assetId: snapshot.assetId, + snapshotId: snapshot.snapshotId, + observedAtUs: snapshot.observedAtUs, + }, + createdAtUs: snapshot.observedAtUs, + }); + } + + current(): Record { + return Object.fromEntries(this.snapshots.entries()); + } +} + +export class PythLiveCollector extends PythCollector { + private readonly priceFeedIdCache = new Map(); + + constructor( + private readonly client: PythLazerClientLike, + private readonly config: PythLiveCollectorConfig, + auditSink?: SnapshotAuditSink, + ) { + super(auditSink); + } + + static async create( + auditSink?: SnapshotAuditSink, + config: Partial = {}, + ) { + if (!runtimeEnv.pythApiKey) { + throw new PythLiveCollectorError( + "PYTH_API_KEY_MISSING", + "PYTH_API_KEY must be configured before creating the live collector", + ); + } + + const client = await PythLazerClient.create({ + token: runtimeEnv.pythApiKey, + webSocketPoolConfig: {}, + ...(runtimeEnv.pythMetadataServiceUrl + ? { metadataServiceUrl: runtimeEnv.pythMetadataServiceUrl } + : {}), + ...(runtimeEnv.pythPriceServiceUrl + ? { priceServiceUrl: runtimeEnv.pythPriceServiceUrl } + : {}), + }); + + return new PythLiveCollector( + client, + { + channel: (config.channel ?? runtimeEnv.pythStreamChannel) as Channel, + pythPolicyId: config.pythPolicyId ?? runtimeEnv.pythPreprodPolicyId, + pythStateReference: + config.pythStateReference ?? runtimeEnv.cardanoPythStateReference, + }, + auditSink, + ); + } + + async resolvePriceFeedId(request: PythLiveFeedRequest): Promise { + if (request.priceFeedId != null) { + return request.priceFeedId; + } + + const cacheKey = `${request.assetType ?? ""}:${request.symbolQuery ?? request.symbol}`; + const cached = this.priceFeedIdCache.get(cacheKey); + if (cached !== undefined) { + return cached; + } + + const matches = await this.client.getSymbols({ + query: request.symbolQuery ?? request.symbol, + ...(request.assetType ? { asset_type: request.assetType } : {}), + }); + + const match = selectBestSymbolMatch(matches, request); + if (!match) { + throw new PythLiveCollectorError( + "PYTH_SYMBOL_NOT_FOUND", + `Unable to resolve a Pyth Lazer price feed for ${request.symbolQuery ?? request.symbol}`, + ); + } + + this.priceFeedIdCache.set(cacheKey, match.pyth_lazer_id); + return match.pyth_lazer_id; + } + + async fetchSignedUpdate(request: PythLiveFeedRequest): Promise { + const resolvedPriceFeedId = await this.resolvePriceFeedId(request); + const raw = await this.client.getLatestPrice({ + channel: this.config.channel, + priceFeedIds: [resolvedPriceFeedId], + properties: [ + "price", + "emaPrice", + "confidence", + "publisherCount", + "exponent", + "marketSession", + "feedUpdateTimestamp", + ], + formats: ["solana"], + parsed: true, + jsonBinaryEncoding: "hex", + }); + + const rawSignedUpdateHex = raw.solana?.data; + if (!rawSignedUpdateHex) { + throw new PythLiveCollectorError( + "PYTH_SIGNED_UPDATE_MISSING", + `Pyth response for ${request.symbol} did not include a solana/Cardano signed update`, + ); + } + const signedUpdateHex = normalizeSignedUpdateHex(rawSignedUpdateHex); + + const parsed = raw.parsed?.priceFeeds.find( + (candidate) => candidate.priceFeedId === resolvedPriceFeedId, + ); + if (!parsed) { + throw new PythLiveCollectorError( + "PYTH_PARSED_FEED_MISSING", + `Pyth response did not include parsed payload for feed ${resolvedPriceFeedId}`, + ); + } + + const observedAtUs = normalizeTimestampUs( + parseRequiredNumber(raw.parsed?.timestampUs, "timestampUs"), + ); + const feedUpdateTimestampUs = normalizeTimestampUs( + parseRequiredNumber(parsed.feedUpdateTimestamp, "feedUpdateTimestamp"), + ); + + const snapshot: OracleSnapshot = { + snapshotId: buildSnapshotId(request.assetId, observedAtUs), + feedId: request.feedId, + assetId: request.assetId, + symbol: request.symbol, + price: parseRequiredNumber(parsed.price, "price"), + emaPrice: parseRequiredNumber(parsed.emaPrice, "emaPrice"), + confidence: parseRequiredNumber(parsed.confidence, "confidence"), + exponent: parseRequiredNumber(parsed.exponent, "exponent"), + feedUpdateTimestampUs, + observedAtUs, + ...(parsed.publisherCount != null + ? { publisherCount: parsed.publisherCount } + : {}), + ...(parsed.marketSession ? { marketSession: parsed.marketSession } : {}), + }; + + const witness = createWitness(signedUpdateHex, this.config); + + return { + snapshot, + witness, + signedUpdateHex, + resolvedPriceFeedId, + raw, + }; + } + + async fetchAndPublish(request: PythLiveFeedRequest): Promise { + const result = await this.fetchSignedUpdate(request); + this.publish(result.snapshot); + return result; + } +} diff --git a/lazer/cardano/guards/source/apps/blockchain/cardano/offchain/env.ts b/lazer/cardano/guards/source/apps/blockchain/cardano/offchain/env.ts new file mode 100644 index 00000000..2877ea78 --- /dev/null +++ b/lazer/cardano/guards/source/apps/blockchain/cardano/offchain/env.ts @@ -0,0 +1,94 @@ +import path from "node:path"; +import { config as loadDotenv } from "dotenv"; + +loadDotenv({ path: path.resolve(process.cwd(), ".env"), quiet: true }); + +function readString(name: string, fallback = ""): string { + const value = process.env[name]; + return typeof value === "string" && value.length > 0 ? value : fallback; +} + +function readNumber(name: string, fallback: number): number { + const value = process.env[name]; + if (!value) { + return fallback; + } + + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : fallback; +} + +function readOptionalNumber(name: string): number | undefined { + const value = process.env[name]; + if (!value) { + return undefined; + } + + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; +} + +export const runtimeEnv = { + appPublicName: readString("APP_PUBLIC_NAME", "guards.one"), + appInternalName: readString("APP_INTERNAL_NAME", "anaconda"), + port: readNumber("PORT", 4310), + pythApiKey: readString("PYTH_API_KEY"), + pythPreprodPolicyId: readString( + "PYTH_PREPROD_POLICY_ID", + "d799d287105dea9377cdf9ea8502a83d2b9eb2d2050a8aea800a21e6", + ), + pythApiBaseUrl: readString("PYTH_API_BASE_URL", "https://api.pyth.network"), + pythMetadataServiceUrl: readString("PYTH_METADATA_SERVICE_URL"), + pythPriceServiceUrl: readString("PYTH_PRICE_SERVICE_URL"), + pythStreamChannel: readString("PYTH_STREAM_CHANNEL", "fixed_rate@200ms"), + pythPrimaryFeedId: readString("PYTH_PRIMARY_FEED_ID", "pyth-ada-usd"), + pythPrimarySymbolQuery: readString("PYTH_PRIMARY_SYMBOL_QUERY", "ADA/USD"), + pythPrimaryAssetType: readString("PYTH_PRIMARY_ASSET_TYPE", "crypto"), + pythPrimaryPriceFeedId: readOptionalNumber("PYTH_PRIMARY_PRICE_FEED_ID"), + cardanoNetwork: readString("CARDANO_NETWORK", "preprod"), + cardanoProvider: readString("CARDANO_PROVIDER", "blockfrost"), + cardanoProviderUrl: readString( + "CARDANO_PROVIDER_URL", + "https://cardano-preprod.blockfrost.io/api/v0", + ), + cardanoBlockfrostProjectId: readString("CARDANO_BLOCKFROST_PROJECT_ID"), + cardanoPythStateReference: readString( + "CARDANO_PYTH_STATE_REFERENCE", + "pyth-state-ref", + ), + cardanoSwapProvider: readString("CARDANO_SWAP_PROVIDER", "dexhunter"), + cardanoProtocolFeeBps: readNumber("CARDANO_PROTOCOL_FEE_BPS", 30), + cardanoMaxTotalFeeBps: readNumber("CARDANO_MAX_TOTAL_FEE_BPS", 100), + cardanoProtocolFeeMode: readString( + "CARDANO_PROTOCOL_FEE_MODE", + "explicit_output", + ), + cardanoExecutionRouteId: readString( + "CARDANO_EXECUTION_ROUTE_ID", + "cardano-minswap-ada-usdm", + ), + dexHunterBaseUrl: readString( + "DEXHUNTER_BASE_URL", + "https://api-us.dexhunterv3.app", + ), + dexHunterPartnerId: readString("DEXHUNTER_PARTNER_ID"), + dexHunterPartnerFeePercent: readNumber("DEXHUNTER_PARTNER_FEE_PERCENT", 0.3), + minswapAggregatorUrl: readString( + "MINSWAP_AGGREGATOR_URL", + "https://agg-api.minswap.org/aggregator", + ), + minswapPartnerCode: readString("MINSWAP_PARTNER_CODE"), + cardanoExecutionHotWalletAddress: readString( + "CARDANO_EXECUTION_HOT_WALLET_ADDRESS", + ), + cardanoExecutionHotWalletSkeyPath: readString( + "CARDANO_EXECUTION_HOT_WALLET_SKEY_PATH", + "./secrets/execution-hot.skey", + ), + cardanoGovernanceWalletAddress: readString("CARDANO_GOVERNANCE_WALLET_ADDRESS"), + cardanoGovernanceSkeyPath: readString( + "CARDANO_GOVERNANCE_SKEY_PATH", + "./secrets/governance.skey", + ), + auditDbPath: readString("AUDIT_DB_PATH", "./data/guards-one.sqlite"), +} as const; diff --git a/lazer/cardano/guards/source/apps/blockchain/cardano/offchain/fixtures.ts b/lazer/cardano/guards/source/apps/blockchain/cardano/offchain/fixtures.ts new file mode 100644 index 00000000..22a5e852 --- /dev/null +++ b/lazer/cardano/guards/source/apps/blockchain/cardano/offchain/fixtures.ts @@ -0,0 +1,59 @@ +import { + buildSnapshots, + samplePolicy, + sampleRoutes, + sampleTreasury, +} from "@anaconda/core"; +import type { CardanoPythWitness } from "@anaconda/cardano"; +import type { PythLiveFeedRequest } from "./types.js"; +import { runtimeEnv } from "./env.js"; + +export const sampleWitness: CardanoPythWitness = { + pythPolicyId: runtimeEnv.pythPreprodPolicyId, + pythStateReference: runtimeEnv.cardanoPythStateReference, + signedUpdateHex: "0xfeedbeef", +}; + +export function buildPrimaryLiveFeedRequest(): PythLiveFeedRequest { + const primaryPosition = sampleTreasury.positions.find( + (position) => position.assetId === samplePolicy.primaryAssetId, + ); + + const request: PythLiveFeedRequest = { + assetId: samplePolicy.primaryAssetId, + feedId: runtimeEnv.pythPrimaryFeedId, + symbol: primaryPosition ? `${primaryPosition.symbol}/USD` : "ADA/USD", + symbolQuery: runtimeEnv.pythPrimarySymbolQuery, + }; + + const assetType = runtimeEnv.pythPrimaryAssetType; + if (assetType) { + request.assetType = assetType as NonNullable; + } + + if (runtimeEnv.pythPrimaryPriceFeedId != null) { + request.priceFeedId = runtimeEnv.pythPrimaryPriceFeedId; + } + + return request; +} + +export function buildDemoScenario() { + return { + treasury: structuredClone(sampleTreasury), + policy: structuredClone(samplePolicy), + routes: structuredClone(sampleRoutes), + snapshots: buildSnapshots({ + ada: { + snapshotId: "snapshot-ada-live", + feedUpdateTimestampUs: 180_000_000, + observedAtUs: 180_000_000, + }, + usdm: { + snapshotId: "snapshot-usdm-live", + feedUpdateTimestampUs: 180_000_000, + observedAtUs: 180_000_000, + }, + }), + }; +} diff --git a/lazer/cardano/guards/source/apps/blockchain/cardano/offchain/index.ts b/lazer/cardano/guards/source/apps/blockchain/cardano/offchain/index.ts new file mode 100644 index 00000000..5295d41f --- /dev/null +++ b/lazer/cardano/guards/source/apps/blockchain/cardano/offchain/index.ts @@ -0,0 +1,4 @@ +export * from "./collector.js"; +export * from "./env.js"; +export * from "./fixtures.js"; +export * from "./types.js"; diff --git a/lazer/cardano/guards/source/apps/blockchain/cardano/offchain/scripts/fetch-live.ts b/lazer/cardano/guards/source/apps/blockchain/cardano/offchain/scripts/fetch-live.ts new file mode 100644 index 00000000..69f9227f --- /dev/null +++ b/lazer/cardano/guards/source/apps/blockchain/cardano/offchain/scripts/fetch-live.ts @@ -0,0 +1,26 @@ +import { buildPrimaryLiveFeedRequest, PythLiveCollector } from "../index.js"; + +const collector = await PythLiveCollector.create(); +const request = buildPrimaryLiveFeedRequest(); +const result = await collector.fetchAndPublish(request); + +console.log( + JSON.stringify( + { + feed: { + assetId: request.assetId, + feedId: request.feedId, + symbol: request.symbol, + resolvedPriceFeedId: result.resolvedPriceFeedId, + }, + snapshot: result.snapshot, + witness: { + pythPolicyId: result.witness.pythPolicyId, + pythStateReference: result.witness.pythStateReference, + signedUpdateHexLength: result.witness.signedUpdateHex.length, + }, + }, + null, + 2, + ), +); diff --git a/lazer/cardano/guards/source/apps/blockchain/cardano/offchain/types.ts b/lazer/cardano/guards/source/apps/blockchain/cardano/offchain/types.ts new file mode 100644 index 00000000..a3f18858 --- /dev/null +++ b/lazer/cardano/guards/source/apps/blockchain/cardano/offchain/types.ts @@ -0,0 +1,52 @@ +import type { + AssetType, + Channel, + JsonUpdate, + PythLazerClient, + SymbolResponse, +} from "@pythnetwork/pyth-lazer-sdk"; +import type { OracleSnapshot } from "@anaconda/core"; +import type { CardanoPythWitness } from "@anaconda/cardano"; + +export interface SnapshotAuditEvent { + eventId: string; + category: "snapshot"; + payload: Record; + createdAtUs: number; +} + +export interface SnapshotAuditSink { + recordSnapshot?(snapshot: OracleSnapshot): void; + recordEvent?(event: SnapshotAuditEvent): void; +} + +export interface PythLiveFeedRequest { + assetId: string; + feedId: string; + symbol: string; + symbolQuery?: string; + priceFeedId?: number; + assetType?: AssetType; +} + +export interface PythSignedUpdateResult { + snapshot: OracleSnapshot; + witness: CardanoPythWitness; + signedUpdateHex: string; + resolvedPriceFeedId: number; + raw: JsonUpdate; +} + +export interface PythLiveCollectorConfig { + channel: Channel; + pythPolicyId: string; + pythStateReference: string; +} + +export interface PythLazerClientLike + extends Pick {} + +export type PythSymbolMatch = Pick< + SymbolResponse, + "pyth_lazer_id" | "symbol" | "name" | "description" | "asset_type" +>; diff --git a/lazer/cardano/guards/source/apps/blockchain/evm/README.md b/lazer/cardano/guards/source/apps/blockchain/evm/README.md new file mode 100644 index 00000000..f6900955 --- /dev/null +++ b/lazer/cardano/guards/source/apps/blockchain/evm/README.md @@ -0,0 +1,14 @@ +# EVM Surface + +This directory is the collaboration surface for EVM execution work. + +Current scaffold: + +- `packages/evm/src/index.ts` +- `packages/evm/src/fixtures.ts` + +Planned work: + +- Safe-aware treasury execution +- DEX route integration +- Pyth EVM verification flow diff --git a/lazer/cardano/guards/source/apps/blockchain/package.json b/lazer/cardano/guards/source/apps/blockchain/package.json new file mode 100644 index 00000000..1dc5f785 --- /dev/null +++ b/lazer/cardano/guards/source/apps/blockchain/package.json @@ -0,0 +1,25 @@ +{ + "name": "@anaconda/blockchain", + "version": "0.0.0", + "private": true, + "type": "module", + "engines": { + "node": ">=24.0.0" + }, + "exports": { + ".": "./src/index.ts", + "./cardano": "./src/cardano.ts", + "./cardano/offchain": "./cardano/offchain/index.ts", + "./svm": "./src/svm.ts", + "./evm": "./src/evm.ts", + "./manifests": "./src/manifests.ts" + }, + "dependencies": { + "@anaconda/cardano": "workspace:*", + "@anaconda/core": "workspace:*", + "@anaconda/evm": "workspace:*", + "@anaconda/svm": "workspace:*", + "@pythnetwork/pyth-lazer-sdk": "^6.2.1", + "dotenv": "^17.2.3" + } +} diff --git a/lazer/cardano/guards/source/apps/blockchain/src/cardano.ts b/lazer/cardano/guards/source/apps/blockchain/src/cardano.ts new file mode 100644 index 00000000..efd6a545 --- /dev/null +++ b/lazer/cardano/guards/source/apps/blockchain/src/cardano.ts @@ -0,0 +1,7 @@ +export * from "@anaconda/cardano"; +export { + buildSnapshots, + samplePolicy, + sampleRoutes, + sampleTreasury, +} from "@anaconda/core"; diff --git a/lazer/cardano/guards/source/apps/blockchain/src/evm.ts b/lazer/cardano/guards/source/apps/blockchain/src/evm.ts new file mode 100644 index 00000000..ee20cf69 --- /dev/null +++ b/lazer/cardano/guards/source/apps/blockchain/src/evm.ts @@ -0,0 +1 @@ +export * from "@anaconda/evm"; diff --git a/lazer/cardano/guards/source/apps/blockchain/src/index.ts b/lazer/cardano/guards/source/apps/blockchain/src/index.ts new file mode 100644 index 00000000..616ee134 --- /dev/null +++ b/lazer/cardano/guards/source/apps/blockchain/src/index.ts @@ -0,0 +1,4 @@ +export * from "./manifests.js"; +export * as cardanoSurface from "./cardano.js"; +export * as svmSurface from "./svm.js"; +export * as evmSurface from "./evm.js"; diff --git a/lazer/cardano/guards/source/apps/blockchain/src/manifests.ts b/lazer/cardano/guards/source/apps/blockchain/src/manifests.ts new file mode 100644 index 00000000..fbb3fa7c --- /dev/null +++ b/lazer/cardano/guards/source/apps/blockchain/src/manifests.ts @@ -0,0 +1,81 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +export interface CollaborationSurface { + chain: "cardano" | "svm" | "evm"; + appRoot: string; + contractsRoot?: string; + offchainRoot?: string; + adapterPackage: string; + currentSourceOfTruth: string[]; + nextArtifacts: string[]; +} + +const manifestDir = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(manifestDir, "../../.."); + +function fromRoot(...segments: string[]): string { + return path.join(repoRoot, ...segments); +} + +export const blockchainSurface: Record< + CollaborationSurface["chain"], + CollaborationSurface +> = { + cardano: { + chain: "cardano", + appRoot: fromRoot("apps", "blockchain", "cardano"), + contractsRoot: fromRoot("apps", "blockchain", "cardano", "contracts"), + offchainRoot: fromRoot("apps", "blockchain", "cardano", "offchain"), + adapterPackage: "@anaconda/cardano", + currentSourceOfTruth: [ + fromRoot("packages", "cardano", "src", "policy-vault.ts"), + fromRoot("packages", "cardano", "src", "types.ts"), + fromRoot("apps", "blockchain", "cardano", "offchain", "collector.ts"), + fromRoot("apps", "blockchain", "cardano", "offchain", "env.ts"), + fromRoot("apps", "blockchain", "cardano", "contracts", "aiken", "lib", "guards_one", "policy_vault.ak"), + fromRoot("apps", "blockchain", "cardano", "contracts", "aiken", "validators", "policy_vault.ak"), + fromRoot("apps", "backend", "src", "keeper.ts"), + ], + nextArtifacts: [ + "Aiken validator for PolicyVault", + "Execution hot-bucket validator rules", + "DexHunter live execution wiring", + "Automatic Pyth State UTxO resolution from Cardano provider", + ], + }, + svm: { + chain: "svm", + appRoot: fromRoot("apps", "blockchain", "svm"), + adapterPackage: "@anaconda/svm", + currentSourceOfTruth: [ + fromRoot("packages", "svm", "src", "index.ts"), + fromRoot("packages", "svm", "src", "fixtures.ts"), + ], + nextArtifacts: [ + "Wallet Standard connector", + "Jupiter route adapter", + "Pyth-native execution path", + ], + }, + evm: { + chain: "evm", + appRoot: fromRoot("apps", "blockchain", "evm"), + adapterPackage: "@anaconda/evm", + currentSourceOfTruth: [ + fromRoot("packages", "evm", "src", "index.ts"), + fromRoot("packages", "evm", "src", "fixtures.ts"), + ], + nextArtifacts: [ + "Safe-aware treasury connector", + "Uniswap route adapter", + "Pyth EVM verification path", + ], + }, +}; + +export const collaborationGuide = { + entrypoint: fromRoot("apps", "blockchain"), + rule: + "New chain-facing work should start in apps/blockchain/* so the team has one obvious collaboration surface. Shared primitives stay in packages/* until a breaking migration is planned.", +}; diff --git a/lazer/cardano/guards/source/apps/blockchain/src/svm.ts b/lazer/cardano/guards/source/apps/blockchain/src/svm.ts new file mode 100644 index 00000000..f9564874 --- /dev/null +++ b/lazer/cardano/guards/source/apps/blockchain/src/svm.ts @@ -0,0 +1 @@ +export * from "@anaconda/svm"; diff --git a/lazer/cardano/guards/source/apps/blockchain/svm/README.md b/lazer/cardano/guards/source/apps/blockchain/svm/README.md new file mode 100644 index 00000000..352d2612 --- /dev/null +++ b/lazer/cardano/guards/source/apps/blockchain/svm/README.md @@ -0,0 +1,14 @@ +# SVM Surface + +This directory is the collaboration surface for Solana/SVM execution work. + +Current scaffold: + +- `packages/svm/src/index.ts` +- `packages/svm/src/fixtures.ts` + +Planned work: + +- wallet connector +- Jupiter integration +- Pyth-native execution flow diff --git a/lazer/cardano/guards/source/apps/blockchain/tests/cardano-offchain.test.ts b/lazer/cardano/guards/source/apps/blockchain/tests/cardano-offchain.test.ts new file mode 100644 index 00000000..1b441122 --- /dev/null +++ b/lazer/cardano/guards/source/apps/blockchain/tests/cardano-offchain.test.ts @@ -0,0 +1,198 @@ +import { describe, expect, it, vi } from "vitest"; +import { + PythCollector, + PythLiveCollector, + PythLiveCollectorError, + type PythLiveFeedRequest, + type SnapshotAuditSink, +} from "@anaconda/blockchain/cardano/offchain"; +import type { OracleSnapshot } from "@anaconda/core"; +import type { SymbolResponse } from "@pythnetwork/pyth-lazer-sdk"; + +const request: PythLiveFeedRequest = { + assetId: "ada", + feedId: "pyth-ada-usd", + symbol: "ADA/USD", + symbolQuery: "ADA/USD", + assetType: "crypto", +}; + +function buildCollectorPayload() { + return { + parsed: { + timestampUs: "1700000000200000", + priceFeeds: [ + { + priceFeedId: 17, + price: "45230000", + emaPrice: "46000000", + confidence: 1200, + exponent: -8, + publisherCount: 14, + marketSession: "active", + feedUpdateTimestamp: 1_700_000_000, + }, + ], + }, + solana: { + encoding: "hex" as const, + data: "deadbeef", + }, + }; +} + +function buildSymbolMatch(): SymbolResponse { + return { + pyth_lazer_id: 17, + symbol: "Crypto.ADA/USD", + name: "ADA/USD", + description: "Cardano spot", + asset_type: "crypto", + exponent: -8, + min_publishers: 1, + min_channel: "fixed_rate@200ms", + state: "active", + schedule: "24/7", + }; +} + +describe("PythCollector", () => { + it("stores snapshots and emits snapshot audit events", () => { + const sink: SnapshotAuditSink = { + recordSnapshot: vi.fn(), + recordEvent: vi.fn(), + }; + const collector = new PythCollector(sink); + const snapshot: OracleSnapshot = { + snapshotId: "snapshot:ada:1", + assetId: "ada", + feedId: "pyth-ada-usd", + symbol: "ADA/USD", + price: 1, + emaPrice: 1, + confidence: 0, + exponent: 0, + feedUpdateTimestampUs: 1, + observedAtUs: 1, + }; + + collector.publish(snapshot); + + expect(collector.current()).toEqual({ ada: snapshot }); + expect(sink.recordSnapshot).toHaveBeenCalledWith(snapshot); + expect(sink.recordEvent).toHaveBeenCalledWith({ + eventId: "snapshot:snapshot:ada:1", + category: "snapshot", + payload: { + assetId: "ada", + snapshotId: "snapshot:ada:1", + observedAtUs: 1, + }, + createdAtUs: 1, + }); + }); +}); + +describe("PythLiveCollector", () => { + it("resolves a symbol, fetches a signed update, and publishes a snapshot", async () => { + const sink: SnapshotAuditSink = { + recordSnapshot: vi.fn(), + recordEvent: vi.fn(), + }; + const client = { + getSymbols: vi.fn(async () => [buildSymbolMatch()]), + getLatestPrice: vi.fn(async () => buildCollectorPayload()), + }; + + const collector = new PythLiveCollector( + client, + { + channel: "fixed_rate@200ms", + pythPolicyId: "policy-id", + pythStateReference: "state-ref", + }, + sink, + ); + + const result = await collector.fetchAndPublish(request); + + expect(client.getSymbols).toHaveBeenCalledTimes(1); + expect(client.getLatestPrice).toHaveBeenCalledWith({ + channel: "fixed_rate@200ms", + priceFeedIds: [17], + properties: [ + "price", + "emaPrice", + "confidence", + "publisherCount", + "exponent", + "marketSession", + "feedUpdateTimestamp", + ], + formats: ["solana"], + parsed: true, + jsonBinaryEncoding: "hex", + }); + expect(result.witness).toEqual({ + pythPolicyId: "policy-id", + pythStateReference: "state-ref", + signedUpdateHex: "0xdeadbeef", + }); + expect(result.snapshot).toMatchObject({ + assetId: "ada", + feedId: "pyth-ada-usd", + symbol: "ADA/USD", + price: 45_230_000, + emaPrice: 46_000_000, + confidence: 1200, + exponent: -8, + publisherCount: 14, + marketSession: "active", + observedAtUs: 1_700_000_000_200_000, + feedUpdateTimestampUs: 1_700_000_000_000_000, + }); + expect(collector.current().ada?.snapshotId).toBe( + result.snapshot.snapshotId, + ); + expect(sink.recordSnapshot).toHaveBeenCalledOnce(); + expect(sink.recordEvent).toHaveBeenCalledOnce(); + }); + + it("caches symbol resolution across multiple fetches", async () => { + const client = { + getSymbols: vi.fn(async () => [buildSymbolMatch()]), + getLatestPrice: vi.fn(async () => buildCollectorPayload()), + }; + + const collector = new PythLiveCollector(client, { + channel: "fixed_rate@200ms", + pythPolicyId: "policy-id", + pythStateReference: "state-ref", + }); + + await collector.fetchSignedUpdate(request); + await collector.fetchSignedUpdate(request); + + expect(client.getSymbols).toHaveBeenCalledTimes(1); + expect(client.getLatestPrice).toHaveBeenCalledTimes(2); + }); + + it("fails closed when the signed update payload is missing", async () => { + const client = { + getSymbols: vi.fn(async () => [buildSymbolMatch()]), + getLatestPrice: vi.fn(async () => ({ + parsed: buildCollectorPayload().parsed, + })), + }; + + const collector = new PythLiveCollector(client, { + channel: "fixed_rate@200ms", + pythPolicyId: "policy-id", + pythStateReference: "state-ref", + }); + + await expect(collector.fetchSignedUpdate(request)).rejects.toMatchObject({ + code: "PYTH_SIGNED_UPDATE_MISSING", + } satisfies Partial); + }); +}); diff --git a/lazer/cardano/guards/source/apps/blockchain/tests/manifests.test.ts b/lazer/cardano/guards/source/apps/blockchain/tests/manifests.test.ts new file mode 100644 index 00000000..475bd602 --- /dev/null +++ b/lazer/cardano/guards/source/apps/blockchain/tests/manifests.test.ts @@ -0,0 +1,24 @@ +import fs from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { blockchainSurface, collaborationGuide } from "../src/manifests.js"; + +describe("blockchain collaboration surface", () => { + it("anchors the app-level blockchain workspace under apps/blockchain", () => { + expect(collaborationGuide.entrypoint.endsWith(path.join("apps", "blockchain"))).toBe(true); + expect(fs.existsSync(collaborationGuide.entrypoint)).toBe(true); + expect(fs.statSync(collaborationGuide.entrypoint).isDirectory()).toBe(true); + }); + + it("declares concrete Cardano collaboration paths", () => { + expect(blockchainSurface.cardano.contractsRoot).toContain( + path.join("apps", "blockchain", "cardano", "contracts"), + ); + expect(fs.existsSync(blockchainSurface.cardano.contractsRoot!)).toBe(true); + expect(fs.statSync(blockchainSurface.cardano.contractsRoot!).isDirectory()).toBe(true); + for (const currentSource of blockchainSurface.cardano.currentSourceOfTruth) { + expect(fs.existsSync(currentSource)).toBe(true); + expect(fs.statSync(currentSource).isFile()).toBe(true); + } + }); +}); diff --git a/lazer/cardano/guards/source/apps/ui-legacy/app.js b/lazer/cardano/guards/source/apps/ui-legacy/app.js new file mode 100644 index 00000000..117f9c75 --- /dev/null +++ b/lazer/cardano/guards/source/apps/ui-legacy/app.js @@ -0,0 +1,617 @@ +const fallbackState = { + source: "static-fallback", + workspace: { + name: "Anaconda Treasury Demo", + label: "guards.one live desk", + chain: "Cardano preprod", + stage: "Normal", + threshold: "3 governance signers", + members: "1 risk manager · 2 keepers", + hotWallet: "addr_test...hot_1", + governanceWallet: "addr_test...gov_1", + totalBalance: "$6,646", + primaryAsset: "ADA", + stableAsset: "USDM", + vaultId: "vault-anaconda-demo", + }, + topbarChips: [ + { label: "Network status", value: "Cardano preprod live", tone: "live" }, + { label: "Approved route", value: "Minswap · USDM", tone: "neutral" }, + { label: "Execution wallet", value: "addr_tes...hot_1", tone: "neutral" }, + ], + heroMetrics: [ + { + label: "Current stage", + value: "Normal", + copy: "Final policy state after the simulated breach and recovery run.", + chip: "ok", + }, + { + label: "Treasury liquid value", + value: "$6,646", + copy: "Valued from the latest Pyth price and haircut-aware liquid value math.", + }, + { + label: "Stable protection", + value: "36.0%", + copy: "Current treasury share parked in the approved stable reserve.", + }, + { + label: "Oracle freshness", + value: "20s", + copy: "Age of the primary feed update relative to the latest policy evaluation.", + }, + ], + dashboardCards: [ + { + label: "Protected floor", + value: "$4,500", + copy: "Minimum fiat-equivalent value the policy tries to keep defended at all times.", + chip: "ok", + }, + { + label: "Emergency floor", + value: "$3,400", + copy: "Crossing this floor escalates the vault into a full stable exit or freeze path.", + chip: "danger", + }, + { + label: "Primary reason", + value: "Cooldown prevents an automatic state transition", + copy: "Top reason emitted by the risk engine for the current snapshot set.", + chip: "warn", + }, + { + label: "Execution policy", + value: "CARDANO · Minswap · USDM", + copy: "Only allowlisted routes can spend from the execution hot wallet.", + chip: "ok", + }, + ], + chainCards: [ + { + chain: "CARDANO", + title: "Live execution surface", + copy: "Cardano is the only live execution target in the MVP. Execution uses a two-step authorize-and-swap flow.", + chip: "live", + }, + { + chain: "SVM", + title: "Scaffolded adapter", + copy: "Scaffolding only in the MVP. Designed to share the same policy and role model.", + chip: "scaffold", + }, + { + chain: "EVM", + title: "Scaffolded adapter", + copy: "Scaffolding only in the MVP. Connectors remain simulation-first until phase 2.", + chip: "scaffold", + }, + ], + riskLadder: [ + { + stage: "Normal", + title: "Operate with full permissions", + copy: "Fresh oracle, healthy confidence, and no forced action path active.", + }, + { + stage: "Watch", + title: "Increase monitoring", + copy: "The drawdown or fiat floor approaches the first trigger band.", + tone: "partial", + }, + { + stage: "Partial De-Risk", + title: "Sell only what restores the safe floor", + copy: "The keeper sells a bounded slice of the risky bucket into the approved stable route.", + tone: "partial", + }, + { + stage: "Full Stable Exit", + title: "Move the vault fully defensive", + copy: "A deeper breach exits the risk bucket and keeps the hot wallet on one stable rail.", + tone: "full", + }, + { + stage: "Auto Re-entry", + title: "Re-risk only after hysteresis clears", + copy: "Recovery must clear a separate band and cooldown before exposure comes back.", + tone: "reentry", + }, + ], + executionTimeline: [ + { + title: "Step 1 · Partial De-Risk", + copy: "Intent intent-1 anchored and settled in tx tx-1.", + status: "executed", + }, + { + title: "Step 2 · Full Stable Exit", + copy: "Intent intent-2 anchored and settled in tx tx-2.", + status: "executed", + }, + { + title: "Step 3 · Normal", + copy: "Intent intent-3 anchored and settled in tx tx-3.", + status: "executed", + }, + ], + auditTrail: [ + { + title: "EXECUTION · tx-915d", + copy: "Settled normal. Sold 4,440 and bought 5,553.", + stamp: "0s", + }, + ], + accounts: [ + { + label: "ADA risk bucket", + address: "addr_tes...hot_1", + balance: "5,553 ADA", + fiatValue: "$4,266", + weight: "64.2%", + role: "Risk asset", + bucket: "Execution hot", + }, + { + label: "USDM stable reserve", + address: "addr_tes...hot_1", + balance: "2,393 USDM", + fiatValue: "$2,393", + weight: "35.8%", + role: "Stable reserve", + bucket: "Execution hot", + }, + ], + portfolioSeries: [ + { label: "Watch", stage: "watch", value: 8400, displayValue: "$8,400" }, + { label: "Partial", stage: "partial_derisk", value: 7310, displayValue: "$7,310" }, + { label: "Exit", stage: "full_exit", value: 4580, displayValue: "$4,580" }, + { label: "Recovery", stage: "normal", value: 6646, displayValue: "$6,646" }, + ], + demoFrames: [ + { + label: "01", + title: "Watchlist breach detected", + copy: "ADA slips under its EMA while Pyth freshness and confidence remain healthy enough to authorize a policy action.", + stage: "watch", + balance: "$8,400", + stableRatio: "17.9%", + reason: "ADA liquid value fell below its protected floor", + }, + { + label: "02", + title: "Partial de-risk executes", + copy: "The keeper emits an intent, swaps only the bounded amount needed, and restores the defended stable floor.", + stage: "partial_derisk", + balance: "$7,310", + stableRatio: "44.0%", + reason: "Partial stable target restored.", + }, + { + label: "03", + title: "Full stable exit after the second leg down", + copy: "A deeper price break and thinner asset cushion push the vault into a full defensive configuration on the approved stable route.", + stage: "full_exit", + balance: "$4,580", + stableRatio: "100.0%", + reason: "Emergency floor forced the full stable path.", + }, + { + label: "04", + title: "Auto re-entry restores exposure", + copy: "Recovery clears the hysteresis band, cooldown expires, and the treasury re-enters risk according to the configured target ratio.", + stage: "normal", + balance: "$6,646", + stableRatio: "36.0%", + reason: "Recovery cleared the re-entry guardrails.", + }, + ], +}; + +const workspaceCard = document.querySelector("#workspace-card"); +const topbarChips = document.querySelector("#topbar-chips"); +const heroMetrics = document.querySelector("#hero-metrics"); +const dashboardCards = document.querySelector("#dashboard-cards"); +const chainList = document.querySelector("#chain-list"); +const ladderList = document.querySelector("#risk-ladder-list"); +const executionTimeline = document.querySelector("#execution-timeline"); +const auditTrail = document.querySelector("#audit-trail"); +const accountsBody = document.querySelector("#accounts-body"); +const overviewStage = document.querySelector("#overview-stage"); +const overviewBalance = document.querySelector("#overview-balance"); +const overviewCopy = document.querySelector("#overview-copy"); +const demoTitle = document.querySelector("#demo-title"); +const demoStage = document.querySelector("#demo-stage"); +const demoCopy = document.querySelector("#demo-copy"); +const frameStrip = document.querySelector("#frame-strip"); +const portfolioChart = document.querySelector("#portfolio-chart"); +const frameBalance = document.querySelector("#frame-balance"); +const frameStableRatio = document.querySelector("#frame-stable-ratio"); +const frameReason = document.querySelector("#frame-reason"); +const replayButton = document.querySelector("#replay-button"); + +const allowedTones = new Set(["ok", "warn", "danger", "live", "neutral", "executed", "rejected"]); +const allowedStages = new Set(["normal", "watch", "partial_derisk", "full_exit", "frozen"]); +const allowedLadderTones = new Set(["", "partial", "full", "reentry"]); +const stageFramesToLadderCount = [2, 3, 4, 5]; +let replayTimer = null; +let currentState = fallbackState; +let activeFrameIndex = Math.max(0, fallbackState.demoFrames.length - 1); + +async function loadState() { + const candidates = ["/api/demo-state", "./data/demo-state.json"]; + + for (const url of candidates) { + try { + const response = await fetch(url); + if (response.ok) { + return await response.json(); + } + } catch { + // Try the next source. + } + } + + return fallbackState; +} + +function escapeHTML(value) { + if (value === null || value === undefined) { + return ""; + } + + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function safeToken(value, allowed, fallback) { + return allowed.has(value) ? value : fallback; +} + +function toneForStage(stage) { + switch (safeToken(stage, allowedStages, "watch")) { + case "normal": + return "ok"; + case "watch": + return "warn"; + case "partial_derisk": + return "warn"; + case "full_exit": + return "danger"; + case "frozen": + return "danger"; + default: + return "neutral"; + } +} + +function humanizeStage(stage) { + return String(stage).replaceAll("_", " "); +} + +function renderWorkspace(workspace) { + return ` +
+
G1
+
+

${escapeHTML(workspace.label)}

+ ${escapeHTML(workspace.name)} +
+
+
+ Total balance + ${escapeHTML(workspace.totalBalance)} +
+
+
+ Stage + ${escapeHTML(workspace.stage)} +
+
+ Chain + ${escapeHTML(workspace.chain)} +
+
+ Primary + ${escapeHTML(workspace.primaryAsset)} +
+
+ Stable + ${escapeHTML(workspace.stableAsset)} +
+
+
+

${escapeHTML(workspace.threshold)}

+

${escapeHTML(workspace.members)}

+

Hot wallet ${escapeHTML(workspace.hotWallet)}

+

Vault ${escapeHTML(workspace.vaultId)}

+
+ `; +} + +function renderTopbarChip({ label, value, tone }) { + const safeTone = safeToken(tone, allowedTones, "neutral"); + return ` +
+ ${escapeHTML(label)} + ${escapeHTML(value)} +
+ `; +} + +function renderMetric({ label, value, copy, chip }) { + const safeChip = safeToken(chip ?? "neutral", allowedTones, "neutral"); + return ` +
+
+ ${escapeHTML(label)} + ${chip ? `` : ""} +
+ ${escapeHTML(value)} +

${escapeHTML(copy)}

+
+ `; +} + +function renderPolicyCard({ label, value, copy, chip }) { + const safeChip = safeToken(chip, allowedTones, "neutral"); + return ` +
+
+ ${escapeHTML(label)} + +
+ ${escapeHTML(value)} +

${escapeHTML(copy)}

+
+ `; +} + +function renderChain({ chain, title, copy, chip }) { + const safeChip = chip === "live" ? "live" : "warn"; + return ` +
+
+ ${escapeHTML(chain)} + ${escapeHTML(chip === "live" ? "Live" : "Scaffold")} +
+ ${escapeHTML(title)} +

${escapeHTML(copy)}

+
+ `; +} + +function renderLadder({ stage, title, copy, tone = "" }, index) { + const safeTone = safeToken(tone ?? "", allowedLadderTones, ""); + const toneClass = safeTone ? ` ladder-${safeTone}` : ""; + return ` +
+ ${escapeHTML(stage)} + ${escapeHTML(title)} +

${escapeHTML(copy)}

+
+ `; +} + +function renderTimelineItem({ title, copy, status }, index) { + const safeStatus = status === "rejected" ? "rejected" : "executed"; + return ` +
+
+ ${escapeHTML(title)} + ${escapeHTML(safeStatus)} +
+

${escapeHTML(copy)}

+
+ `; +} + +function renderAuditItem({ title, copy, stamp }) { + return ` +
+
+ ${escapeHTML(title)} + ${escapeHTML(stamp)} ago +
+

${escapeHTML(copy)}

+
+ `; +} + +function renderAccountRow({ label, address, balance, fiatValue, weight, role, bucket }) { + return ` + + + + + ${escapeHTML(balance)} + ${escapeHTML(fiatValue)} + ${escapeHTML(weight)} + + `; +} + +function renderFramePill(frame, index, activeIndex) { + const safeTone = toneForStage(frame.stage); + const activeClass = index === activeIndex ? " active" : ""; + return ` + + `; +} + +function renderChart(series, activeIndex) { + if (!Array.isArray(series) || series.length === 0) { + return ""; + } + + const width = 640; + const height = 280; + const padX = 36; + const padTop = 18; + const padBottom = 44; + const values = series.map((point) => Number(point.value) || 0); + const min = Math.min(...values); + const max = Math.max(...values); + const span = Math.max(1, max - min); + const innerWidth = width - padX * 2; + const innerHeight = height - padTop - padBottom; + + const x = (index) => padX + (innerWidth * index) / Math.max(1, series.length - 1); + const y = (value) => padTop + (1 - (value - min) / span) * innerHeight; + + const linePath = series + .map((point, index) => `${index === 0 ? "M" : "L"} ${x(index).toFixed(2)} ${y(point.value).toFixed(2)}`) + .join(" "); + const areaPath = `${linePath} L ${x(series.length - 1).toFixed(2)} ${(height - padBottom).toFixed(2)} L ${x(0).toFixed(2)} ${(height - padBottom).toFixed(2)} Z`; + + const grid = Array.from({ length: 3 }, (_, index) => { + const yPos = padTop + (innerHeight * index) / 2; + return ``; + }).join(""); + + const points = series + .map((point, index) => { + const tone = toneForStage(point.stage); + const activeClass = index === activeIndex ? " active" : ""; + return ` + + + ${escapeHTML(point.label)} + + `; + }) + .join(""); + + const activeValue = series[activeIndex]?.displayValue ?? ""; + const activeX = x(activeIndex); + const activeY = y(series[activeIndex]?.value ?? 0); + + return ` + + + + + + + ${grid} + + + ${points} + + + ${escapeHTML(activeValue)} + + `; +} + +function setStagePill(element, label, stage) { + const tone = toneForStage(stage); + element.textContent = label; + element.className = `status-pill tone-${tone}`; +} + +function applyFrame(state, index) { + const frames = Array.isArray(state.demoFrames) ? state.demoFrames : []; + if (frames.length === 0) { + return; + } + + activeFrameIndex = Math.max(0, Math.min(index, frames.length - 1)); + const frame = frames[activeFrameIndex]; + demoTitle.textContent = frame.title; + demoCopy.textContent = frame.copy; + setStagePill(demoStage, humanizeStage(frame.stage), frame.stage); + frameBalance.textContent = frame.balance; + frameStableRatio.textContent = frame.stableRatio; + frameReason.textContent = frame.reason; + frameStrip.innerHTML = frames.map((item, frameIndex) => renderFramePill(item, frameIndex, activeFrameIndex)).join(""); + portfolioChart.innerHTML = renderChart(state.portfolioSeries ?? [], activeFrameIndex); + + const timelineItems = executionTimeline.querySelectorAll(".timeline-item"); + timelineItems.forEach((item, timelineIndex) => { + item.classList.toggle("is-complete", timelineIndex < activeFrameIndex - 1); + item.classList.toggle("is-active", timelineIndex === activeFrameIndex - 1); + }); + + const ladderCards = ladderList.querySelectorAll(".ladder-card"); + const activeCount = stageFramesToLadderCount[Math.min(activeFrameIndex, stageFramesToLadderCount.length - 1)] ?? ladderCards.length; + ladderCards.forEach((item, ladderIndex) => { + item.classList.toggle("is-active", ladderIndex < activeCount); + }); +} + +function startReplay() { + if (replayTimer) { + clearInterval(replayTimer); + replayTimer = null; + } + + applyFrame(currentState, 0); + replayTimer = window.setInterval(() => { + const nextIndex = activeFrameIndex + 1; + if (nextIndex >= currentState.demoFrames.length) { + clearInterval(replayTimer); + replayTimer = null; + return; + } + + applyFrame(currentState, nextIndex); + }, 1400); +} + +function renderState(state) { + currentState = state; + workspaceCard.innerHTML = renderWorkspace(state.workspace); + topbarChips.innerHTML = state.topbarChips.map(renderTopbarChip).join(""); + heroMetrics.innerHTML = state.heroMetrics.map(renderMetric).join(""); + dashboardCards.innerHTML = state.dashboardCards.map(renderPolicyCard).join(""); + chainList.innerHTML = state.chainCards.map(renderChain).join(""); + ladderList.innerHTML = state.riskLadder.map(renderLadder).join(""); + executionTimeline.innerHTML = state.executionTimeline.map(renderTimelineItem).join(""); + auditTrail.innerHTML = (state.auditTrail ?? []).map(renderAuditItem).join(""); + accountsBody.innerHTML = state.accounts.map(renderAccountRow).join(""); + + overviewBalance.textContent = state.workspace.totalBalance; + overviewCopy.textContent = state.dashboardCards[2]?.value ?? "Policy is currently inside the safe band."; + setStagePill(overviewStage, state.workspace.stage, state.demoFrames.at(-1)?.stage ?? "normal"); + applyFrame(state, Math.max(0, state.demoFrames.length - 1)); +} + +frameStrip.addEventListener("click", (event) => { + const target = event.target.closest("[data-frame-index]"); + if (!(target instanceof HTMLElement)) { + return; + } + + const index = Number(target.dataset.frameIndex); + if (!Number.isFinite(index)) { + return; + } + + if (replayTimer) { + clearInterval(replayTimer); + replayTimer = null; + } + + applyFrame(currentState, index); +}); + +replayButton.addEventListener("click", () => { + startReplay(); +}); + +loadState().then((state) => { + renderState(state); +}); diff --git a/lazer/cardano/guards/source/apps/ui-legacy/data/demo-state.json b/lazer/cardano/guards/source/apps/ui-legacy/data/demo-state.json new file mode 100644 index 00000000..726f5b53 --- /dev/null +++ b/lazer/cardano/guards/source/apps/ui-legacy/data/demo-state.json @@ -0,0 +1,268 @@ +{ + "generatedAtUs": 360000000, + "source": "backend-demo", + "workspace": { + "name": "Anaconda Treasury Demo", + "label": "guards.one live desk", + "chain": "Cardano preprod", + "stage": "Normal", + "threshold": "3 governance signers", + "members": "1 risk manager · 2 keepers", + "hotWallet": "addr_tes..._hot_1", + "governanceWallet": "addr_tes..._gov_1", + "totalBalance": "$6,646", + "primaryAsset": "ADA", + "stableAsset": "USDM", + "vaultId": "vault-anaconda-demo" + }, + "topbarChips": [ + { + "label": "Network status", + "value": "Cardano preprod live", + "tone": "live" + }, + { + "label": "Approved route", + "value": "Minswap · USDM", + "tone": "neutral" + }, + { + "label": "Execution wallet", + "value": "addr_tes..._hot_1", + "tone": "neutral" + } + ], + "heroMetrics": [ + { + "label": "Current stage", + "value": "Normal", + "copy": "Final policy state after the simulated breach and recovery run.", + "chip": "ok" + }, + { + "label": "Treasury liquid value", + "value": "$6,646", + "copy": "Valued from the latest Pyth price and haircut-aware liquid value math." + }, + { + "label": "Stable protection", + "value": "36.0%", + "copy": "Current treasury share parked in the approved stable reserve." + }, + { + "label": "Oracle freshness", + "value": "20s", + "copy": "Age of the primary feed update relative to the latest policy evaluation." + } + ], + "dashboardCards": [ + { + "label": "Protected floor", + "value": "$4,500", + "copy": "Minimum fiat-equivalent value the policy tries to keep defended at all times.", + "chip": "ok" + }, + { + "label": "Emergency floor", + "value": "$3,400", + "copy": "Crossing this floor escalates the vault into a full stable exit or freeze path.", + "chip": "danger" + }, + { + "label": "Primary reason", + "value": "Cooldown prevents an automatic state transition", + "copy": "Top reason emitted by the risk engine for the current snapshot set.", + "chip": "warn" + }, + { + "label": "Execution policy", + "value": "CARDANO · Minswap · USDM", + "copy": "Only allowlisted routes can spend from the execution hot wallet.", + "chip": "ok" + } + ], + "chainCards": [ + { + "chain": "CARDANO", + "title": "Live execution surface", + "copy": "Cardano is the only live execution target in the MVP. Execution uses a two-step authorize-and-swap flow.", + "chip": "live" + }, + { + "chain": "SVM", + "title": "Scaffolded adapter", + "copy": "Scaffolding only in the MVP. Designed to share the same policy and role model.", + "chip": "scaffold" + }, + { + "chain": "EVM", + "title": "Scaffolded adapter", + "copy": "Scaffolding only in the MVP. Connectors remain simulation-first until phase 2.", + "chip": "scaffold" + } + ], + "riskLadder": [ + { + "stage": "Normal", + "title": "Operate with full permissions", + "copy": "Fresh oracle, healthy confidence, and no forced action path active." + }, + { + "stage": "Watch", + "title": "Increase monitoring", + "copy": "The drawdown or fiat floor approaches the first trigger band.", + "tone": "partial" + }, + { + "stage": "Partial De-Risk", + "title": "Sell only what restores the safe floor", + "copy": "The keeper sells a bounded slice of the risky bucket into the approved stable route.", + "tone": "partial" + }, + { + "stage": "Full Stable Exit", + "title": "Move the vault fully defensive", + "copy": "A deeper breach exits the risk bucket and keeps the hot wallet on one stable rail.", + "tone": "full" + }, + { + "stage": "Auto Re-entry", + "title": "Re-risk only after hysteresis clears", + "copy": "Recovery must clear a separate band and cooldown before exposure comes back.", + "tone": "reentry" + } + ], + "executionTimeline": [ + { + "title": "Step 1 · Partial De-Risk", + "copy": "Intent 815ede7a-6cf0-49fe-939f-262dfa1b6a4c anchored and settled in tx tx-8eb5d881-9efe-4ae5-9cb8-11d7a5d06424.", + "status": "executed" + }, + { + "title": "Step 2 · Full Stable Exit", + "copy": "Intent 27535a6c-6d30-4a06-9e8b-655c620913c8 anchored and settled in tx tx-b713b68d-6d8e-42d3-8ec6-f20e66c0a61f.", + "status": "executed" + }, + { + "title": "Step 3 · Normal", + "copy": "Intent 8205bd55-2e1e-452d-b0ca-5c1889e371d4 anchored and settled in tx tx-41e2236d-9cd7-4f9b-958b-b9de83246025.", + "status": "executed" + } + ], + "auditTrail": [ + { + "title": "INTENT · intent:27535a6c-6d30-4a06-9e8b-655c620913c8", + "copy": "Authorized derisk swap at stage full exit.", + "stamp": "100s" + }, + { + "title": "EXECUTION · execution:tx-b713b68d-6d8e-42d3-8ec6-f20e66c0a61f", + "copy": "Settled full exit. Sold 5,466 and bought 2,106.", + "stamp": "100s" + }, + { + "title": "SNAPSHOT · snapshot:snapshot-ada-recovery", + "copy": "Observed ADA from snapshot-ada-recovery.", + "stamp": "20s" + }, + { + "title": "SNAPSHOT · snapshot:snapshot-usdm-3", + "copy": "Observed USDM from snapshot-usdm-3.", + "stamp": "20s" + }, + { + "title": "INTENT · intent:8205bd55-2e1e-452d-b0ca-5c1889e371d4", + "copy": "Authorized reentry swap at stage normal.", + "stamp": "0s" + }, + { + "title": "EXECUTION · execution:tx-41e2236d-9cd7-4f9b-958b-b9de83246025", + "copy": "Settled normal. Sold 4,440 and bought 5,553.", + "stamp": "0s" + } + ], + "accounts": [ + { + "label": "ADA risk bucket", + "address": "addr_tes..._hot_1", + "balance": "5,553 ADA", + "fiatValue": "$4,255", + "weight": "64.0%", + "role": "Risk asset", + "bucket": "Execution hot" + }, + { + "label": "USDM stable reserve", + "address": "addr_tes..._hot_1", + "balance": "2,391 USDM", + "fiatValue": "$2,391", + "weight": "36.0%", + "role": "Stable reserve", + "bucket": "Execution hot" + } + ], + "portfolioSeries": [ + { + "label": "Watch", + "stage": "watch", + "value": 8775, + "displayValue": "$8,775" + }, + { + "label": "Partial", + "stage": "partial_derisk", + "value": 8542.754226799423, + "displayValue": "$8,543" + }, + { + "label": "Exit", + "stage": "full_exit", + "value": 6831.30402061, + "displayValue": "$6,831" + }, + { + "label": "Recovery", + "stage": "normal", + "value": 6646.407945987443, + "displayValue": "$6,646" + } + ], + "demoFrames": [ + { + "label": "01", + "title": "Watchlist breach detected", + "copy": "ADA slips under its EMA while Pyth freshness and confidence remain healthy enough to authorize a policy action.", + "stage": "watch", + "balance": "$8,775", + "stableRatio": "17.1%", + "reason": "Primary asset drawdown crossed watch threshold" + }, + { + "label": "02", + "title": "Partial de-risk executes", + "copy": "The keeper emits an intent, swaps only the bounded amount needed, and restores the defended stable floor.", + "stage": "partial_derisk", + "balance": "$8,543", + "stableRatio": "55.3%", + "reason": "Primary asset drawdown crossed watch threshold" + }, + { + "label": "03", + "title": "Full stable exit after the second leg down", + "copy": "A deeper price break and thinner asset cushion push the vault into a full defensive configuration on the approved stable route.", + "stage": "full_exit", + "balance": "$6,831", + "stableRatio": "100.0%", + "reason": "Primary asset drawdown crossed watch threshold" + }, + { + "label": "04", + "title": "Auto re-entry restores exposure", + "copy": "Recovery clears the hysteresis band, cooldown expires, and the treasury re-enters risk according to the configured target ratio.", + "stage": "normal", + "balance": "$6,646", + "stableRatio": "36.0%", + "reason": "Cooldown prevents an automatic state transition" + } + ] +} diff --git a/lazer/cardano/guards/source/apps/ui-legacy/index.html b/lazer/cardano/guards/source/apps/ui-legacy/index.html new file mode 100644 index 00000000..51321e13 --- /dev/null +++ b/lazer/cardano/guards/source/apps/ui-legacy/index.html @@ -0,0 +1,203 @@ + + + + + + + guards.one + + + + + + +
+ + +
+
+
+

Cardano live · Pyth-backed controls

+

Treasury Control Center

+
+
+
+ +
+
+
+
+
+

Overview

+

Liquid treasury value

+
+ +
+ +
+
+

Current total balance

+
+

+
+
+ + Open runbook + Read spec +
+
+ +
+
+ +
+
+
+

Simulation

+

Demo frame

+
+ +
+ +

+
+
+ +
+
+
+ Frame balance + +
+
+ Stable ratio + +
+
+ Trigger + +
+
+
+
+ +
+
+
+
+

Accounts

+

Hot and cold treasury buckets

+
+
+
+ + + + + + + + + + +
AccountBalanceFiat valueWeight
+
+
+ +
+
+
+
+

Policy

+

Guardrails

+
+
+
+
+ +
+
+
+

Multichain

+

Execution surfaces

+
+
+
+
+
+
+ +
+
+
+
+

Risk ladder

+

Escalation path

+
+
+
+
+ +
+
+
+

Execution

+

Runbook timeline

+
+
+
+
+
+ +
+
+
+

Audit log

+

Deterministic replay trail

+
+
+
+
+
+
+
+ + + + diff --git a/lazer/cardano/guards/source/apps/ui-legacy/styles.css b/lazer/cardano/guards/source/apps/ui-legacy/styles.css new file mode 100644 index 00000000..46c703e9 --- /dev/null +++ b/lazer/cardano/guards/source/apps/ui-legacy/styles.css @@ -0,0 +1,881 @@ +:root { + color-scheme: dark; + --bg: #17181d; + --bg-soft: #1c1e25; + --panel: #212329; + --panel-2: #272932; + --panel-3: #1b1d24; + --line: #31343d; + --line-soft: #292c36; + --text: #f4f2ec; + --muted: #9b9ca4; + --muted-strong: #c6c8ce; + --green: #22c55e; + --yellow: #f0bf5f; + --red: #ef6f6c; + --white-chip: #f6f3ee; + --shadow: 0 20px 56px rgba(0, 0, 0, 0.28); +} + +* { + box-sizing: border-box; +} + +html { + scroll-behavior: smooth; +} + +body { + margin: 0; + min-height: 100vh; + background: + radial-gradient(circle at top right, rgba(255, 255, 255, 0.06), transparent 30%), + radial-gradient(circle at top left, rgba(255, 255, 255, 0.03), transparent 20%), + linear-gradient(180deg, #18191f 0%, #15161b 100%); + color: var(--text); + font-family: "Manrope", system-ui, sans-serif; +} + +body::before { + content: ""; + position: fixed; + inset: 0; + pointer-events: none; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.03), transparent 22%); + opacity: 0.4; +} + +a { + color: inherit; + text-decoration: none; +} + +button, +a { + transition: 160ms ease; +} + +button { + font: inherit; +} + +.app-shell { + display: grid; + grid-template-columns: 308px minmax(0, 1fr); + min-height: 100vh; +} + +.sidebar { + position: sticky; + top: 0; + align-self: start; + min-height: 100vh; + padding: 28px 20px; + border-right: 1px solid var(--line-soft); + background: rgba(22, 24, 31, 0.9); + backdrop-filter: blur(18px); + display: grid; + gap: 18px; +} + +.brand { + display: flex; + align-items: center; + gap: 14px; + padding: 4px 10px 16px; +} + +.brand-mark { + width: 40px; + height: 40px; + border-radius: 14px; + background: linear-gradient(180deg, #fcfaf6, #d8d5cd); + display: inline-flex; + align-items: center; + justify-content: center; + box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.06); +} + +.brand-mark span { + width: 18px; + height: 18px; + border: 2px solid #1a1b20; + border-radius: 5px; +} + +.brand-copy { + display: grid; + gap: 2px; +} + +.brand-copy strong { + font-size: 1.15rem; + letter-spacing: -0.04em; +} + +.brand-copy small, +.side-label, +.workspace-kicker, +.panel-eyebrow, +.eyebrow, +.audit-stamp, +.frame-label, +.ladder-stage, +.metric-top span, +.card-top span, +.summary-block span, +.account-cell span, +.workspace-balance-row span, +.workspace-meta-grid span, +.workspace-foot p, +th { + font-family: "IBM Plex Mono", monospace; +} + +.brand-copy small, +.side-label, +.workspace-kicker, +.panel-eyebrow, +.eyebrow, +.audit-stamp, +.frame-label, +.ladder-stage, +.metric-top span, +.card-top span, +.summary-block span, +.account-cell span, +.workspace-balance-row span, +.workspace-meta-grid span, +.workspace-foot p { + color: var(--muted); + font-size: 0.72rem; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.panel, +.panel-soft { + background: linear-gradient(180deg, rgba(35, 37, 45, 0.96), rgba(29, 31, 38, 0.96)); + border: 1px solid var(--line); + border-radius: 24px; + box-shadow: var(--shadow); +} + +.panel-soft { + padding: 18px; + box-shadow: none; +} + +.workspace-card { + display: grid; + gap: 16px; +} + +.workspace-head { + display: flex; + align-items: center; + gap: 14px; +} + +.workspace-avatar { + width: 52px; + height: 52px; + border-radius: 16px; + background: linear-gradient(180deg, #f8f5ef, #d5d2cb); + color: #17181d; + display: inline-flex; + align-items: center; + justify-content: center; + font-weight: 800; + letter-spacing: -0.04em; +} + +.workspace-head strong, +.workspace-balance-row strong, +.workspace-meta-grid strong, +.workspace-balance, +.balance-value, +.metric-card strong, +.policy-card strong, +.chain-card strong, +.ladder-card strong, +.summary-block strong, +.account-cell strong, +.timeline-item strong, +.audit-item strong, +.topbar h1, +.balance-panel h2, +.chart-panel h2, +.panel h2 { + letter-spacing: -0.04em; +} + +.workspace-balance-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 14px 0 10px; + border-top: 1px solid var(--line-soft); + border-bottom: 1px solid var(--line-soft); +} + +.workspace-balance-row strong { + font-size: 1.8rem; +} + +.workspace-meta-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.workspace-meta-grid article { + padding: 12px; + border-radius: 16px; + background: rgba(255, 255, 255, 0.03); + border: 1px solid var(--line-soft); + display: grid; + gap: 6px; +} + +.workspace-foot { + display: grid; + gap: 8px; +} + +.workspace-foot p { + margin: 0; +} + +.sidebar-nav { + display: grid; + gap: 10px; +} + +.nav-item { + min-height: 52px; + padding: 0 16px; + border-radius: 18px; + border: 1px solid transparent; + display: flex; + align-items: center; + color: var(--muted-strong); + background: transparent; + font-weight: 600; +} + +.nav-item:hover, +.nav-item:focus-visible, +.nav-item.active { + background: var(--panel-3); + border-color: var(--line); + color: var(--text); +} + +.sidebar-foot { + align-self: end; +} + +.doc-links { + display: grid; + gap: 10px; +} + +.doc-links a { + padding: 12px 14px; + border-radius: 14px; + border: 1px solid var(--line-soft); + background: rgba(255, 255, 255, 0.03); + color: var(--muted-strong); +} + +.doc-links a:hover, +.doc-links a:focus-visible { + color: var(--text); + border-color: #484b56; +} + +.workspace { + padding: 24px 28px 36px; +} + +.topbar { + display: flex; + justify-content: space-between; + align-items: start; + gap: 18px; + margin-bottom: 22px; +} + +.eyebrow { + margin: 0 0 10px; +} + +.topbar h1 { + margin: 0; + font-size: clamp(2.15rem, 3.8vw, 3.5rem); +} + +.topbar-chips { + display: flex; + flex-wrap: wrap; + justify-content: end; + gap: 12px; +} + +.status-chip, +.status-pill, +.chip { + display: inline-flex; + align-items: center; + gap: 10px; + border-radius: 16px; + border: 1px solid var(--line); +} + +.status-chip { + min-height: 52px; + padding: 0 18px; + background: var(--panel-3); +} + +.status-chip strong { + font-size: 1rem; +} + +.status-chip span { + color: var(--muted); + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.status-pill, +.chip { + min-height: 34px; + padding: 0 12px; + background: rgba(255, 255, 255, 0.04); + font-size: 0.84rem; + font-weight: 700; +} + +.status-pill.subtle { + background: var(--panel-3); +} + +.tone-live::before, +.status-pill::before, +.mini-dot { + content: ""; + width: 8px; + height: 8px; + border-radius: 999px; + background: currentColor; + display: inline-block; +} + +.tone-live { + color: var(--green); +} + +.tone-neutral { + color: var(--muted-strong); +} + +.tone-ok { + color: var(--green); +} + +.tone-warn { + color: var(--yellow); +} + +.tone-danger { + color: var(--red); +} + +.content { + display: grid; + gap: 20px; +} + +.overview-grid, +.content-grid, +.split-grid { + display: grid; + gap: 20px; +} + +.overview-grid { + grid-template-columns: 1.15fr 1fr; +} + +.content-grid { + grid-template-columns: 1.2fr 0.88fr; +} + +.split-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.side-stack { + display: grid; + gap: 20px; +} + +.panel { + padding: 24px; +} + +.panel-header { + display: flex; + justify-content: space-between; + align-items: start; + gap: 18px; + margin-bottom: 22px; +} + +.panel-header.compact { + margin-bottom: 18px; +} + +.panel-header h2, +.balance-panel h2, +.chart-panel h2 { + margin: 0; + font-size: clamp(1.35rem, 2.4vw, 2rem); +} + +.panel-copy, +.balance-copy, +.metric-card p, +.policy-card p, +.chain-card p, +.ladder-card p, +.timeline-item p, +.audit-item p { + margin: 0; + color: var(--muted); + line-height: 1.6; +} + +.balance-row { + display: flex; + justify-content: space-between; + align-items: end; + gap: 20px; + padding-bottom: 22px; + border-bottom: 1px solid var(--line-soft); +} + +.balance-label { + margin: 0 0 8px; + color: var(--muted); +} + +.balance-value { + font-size: clamp(2.8rem, 5vw, 4.6rem); + font-weight: 800; + line-height: 0.95; +} + +.action-row { + display: flex; + flex-wrap: wrap; + justify-content: end; + gap: 12px; +} + +.button { + min-height: 48px; + padding: 0 18px; + border-radius: 16px; + border: 1px solid transparent; + display: inline-flex; + align-items: center; + justify-content: center; + font-weight: 700; + cursor: pointer; +} + +.button-primary { + background: var(--white-chip); + color: #17181d; +} + +.button-secondary { + background: rgba(255, 255, 255, 0.03); + border-color: var(--line); + color: var(--text); +} + +.button:hover, +.button:focus-visible { + transform: translateY(-1px); + border-color: #555966; +} + +.metric-grid, +.policy-grid, +.chain-list, +.ladder-list, +.timeline-list, +.audit-list { + display: grid; + gap: 14px; +} + +.metric-grid { + margin-top: 22px; + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.metric-card, +.policy-card, +.chain-card, +.ladder-card, +.timeline-item, +.audit-item { + padding: 18px; + border-radius: 18px; + border: 1px solid var(--line-soft); + background: rgba(255, 255, 255, 0.03); +} + +.metric-card { + min-height: 142px; + display: grid; + align-content: space-between; + gap: 10px; +} + +.metric-card strong { + font-size: 1.55rem; +} + +.metric-top, +.card-top { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; +} + +.policy-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.policy-card, +.chain-card, +.ladder-card, +.timeline-item, +.audit-item { + display: grid; + gap: 10px; +} + +.policy-card strong, +.chain-card strong, +.ladder-card strong, +.timeline-item strong, +.audit-item strong { + font-size: 1.06rem; +} + +.frame-strip { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; + margin-bottom: 18px; +} + +.frame-pill { + width: 100%; + padding: 14px; + border-radius: 18px; + border: 1px solid var(--line-soft); + background: rgba(255, 255, 255, 0.03); + color: inherit; + text-align: left; + display: grid; + gap: 6px; + cursor: pointer; +} + +.frame-pill strong { + font-size: 0.96rem; + letter-spacing: -0.03em; +} + +.frame-pill small { + font-size: 0.78rem; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.frame-pill.active, +.frame-pill:hover, +.frame-pill:focus-visible { + border-color: #5a5e69; + background: rgba(255, 255, 255, 0.06); +} + +.chart-shell { + min-height: 280px; + border-radius: 22px; + border: 1px solid var(--line-soft); + background: + radial-gradient(circle at top center, rgba(255, 255, 255, 0.05), transparent 36%), + linear-gradient(180deg, rgba(255, 255, 255, 0.03), rgba(255, 255, 255, 0.01)); + padding: 8px; + overflow: hidden; +} + +#portfolio-chart { + width: 100%; + height: 280px; + display: block; +} + +.chart-grid-line { + stroke: rgba(255, 255, 255, 0.1); + stroke-width: 1; +} + +.chart-area { + fill: url(#chartAreaGradient); +} + +.chart-line { + fill: none; + stroke: rgba(255, 255, 255, 0.9); + stroke-width: 2.6; + stroke-linecap: round; + stroke-linejoin: round; +} + +.chart-point { + stroke: #17181d; + stroke-width: 3; +} + +.chart-point.tone-ok, +.chart-point.active.tone-ok { + fill: var(--green); +} + +.chart-point.tone-warn, +.chart-point.active.tone-warn { + fill: var(--yellow); +} + +.chart-point.tone-danger, +.chart-point.active.tone-danger { + fill: var(--red); +} + +.chart-label, +.chart-value { + fill: var(--muted-strong); + font-family: "IBM Plex Mono", monospace; +} + +.chart-label { + font-size: 11px; +} + +.chart-bubble rect { + fill: rgba(255, 255, 255, 0.08); + stroke: rgba(255, 255, 255, 0.16); +} + +.chart-value { + font-size: 12px; +} + +.demo-summary { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 14px; + margin-top: 18px; +} + +.summary-block { + padding: 16px; + border-radius: 18px; + border: 1px solid var(--line-soft); + background: rgba(255, 255, 255, 0.03); + display: grid; + gap: 10px; +} + +.summary-block strong { + font-size: 1.18rem; +} + +.summary-reason strong { + font-size: 0.96rem; + line-height: 1.5; +} + +.table-wrap { + overflow-x: auto; +} + +.table-wrap table { + width: 100%; + border-collapse: collapse; +} + +th, +td { + text-align: left; + padding: 14px 10px; + border-bottom: 1px solid var(--line-soft); +} + +th { + color: var(--muted); + font-size: 0.72rem; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.account-cell { + display: grid; + gap: 6px; +} + +.account-cell strong { + font-size: 1rem; +} + +.ladder-list { + grid-template-columns: repeat(5, minmax(0, 1fr)); +} + +.ladder-card { + min-height: 188px; + align-content: start; + opacity: 0.62; +} + +.ladder-card.is-active { + opacity: 1; + border-color: #565a67; + background: rgba(255, 255, 255, 0.05); +} + +.ladder-card.ladder-partial { + border-top: 1px solid rgba(240, 191, 95, 0.45); +} + +.ladder-card.ladder-full { + border-top: 1px solid rgba(239, 111, 108, 0.45); +} + +.ladder-card.ladder-reentry { + border-top: 1px solid rgba(34, 197, 94, 0.45); +} + +.timeline-item, +.audit-item { + position: relative; +} + +.timeline-item.is-active, +.audit-item:hover { + border-color: #565a67; + background: rgba(255, 255, 255, 0.05); +} + +.timeline-item.is-complete { + opacity: 0.72; +} + +.chip { + font-size: 0.74rem; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.chip-live { + color: var(--green); +} + +.chip-warn { + color: var(--yellow); +} + +@media (max-width: 1400px) { + .overview-grid, + .content-grid, + .split-grid { + grid-template-columns: 1fr; + } + + .ladder-list { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 1080px) { + .app-shell { + grid-template-columns: 1fr; + } + + .sidebar { + position: static; + min-height: auto; + border-right: 0; + border-bottom: 1px solid var(--line-soft); + } + + .metric-grid, + .policy-grid, + .frame-strip, + .demo-summary { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 720px) { + .workspace { + padding: 18px; + } + + .sidebar { + padding: 18px; + } + + .topbar, + .balance-row, + .panel-header { + flex-direction: column; + align-items: stretch; + } + + .topbar-chips, + .action-row { + justify-content: stretch; + } + + .metric-grid, + .policy-grid, + .frame-strip, + .demo-summary, + .ladder-list, + .workspace-meta-grid { + grid-template-columns: 1fr; + } + + .status-chip, + .button, + .nav-item { + width: 100%; + } + + .balance-value { + font-size: 2.8rem; + } +} diff --git a/lazer/cardano/guards/source/apps/ui/.gitignore b/lazer/cardano/guards/source/apps/ui/.gitignore new file mode 100644 index 00000000..a35d14fd --- /dev/null +++ b/lazer/cardano/guards/source/apps/ui/.gitignore @@ -0,0 +1,4 @@ +.next/ +node_modules/ +out/ +*.tsbuildinfo diff --git a/lazer/cardano/guards/source/apps/ui/app/api/oracle/primary/route.ts b/lazer/cardano/guards/source/apps/ui/app/api/oracle/primary/route.ts new file mode 100644 index 00000000..b4f7f513 --- /dev/null +++ b/lazer/cardano/guards/source/apps/ui/app/api/oracle/primary/route.ts @@ -0,0 +1,232 @@ +import { NextResponse } from "next/server"; +import path from "node:path"; +import { config as loadDotenv } from "dotenv"; +import { + PythLazerClient, + type AssetType, + type Channel, + type SymbolResponse, +} from "@pythnetwork/pyth-lazer-sdk"; +import type { OracleSnapshot } from "@/lib/types"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +loadDotenv({ path: path.resolve(process.cwd(), ".env"), quiet: true }); +loadDotenv({ path: path.resolve(process.cwd(), "..", "..", ".env"), quiet: true }); + +let cachedClient: PythLazerClient | null = null; +let cachedClientPromise: Promise | null = null; +let cachedResolvedPriceFeedId: number | null = null; + +function toDecimal(value: number, exponent: number): number { + return value * 10 ** exponent; +} + +function readString(name: string, fallback = ""): string { + const value = process.env[name]; + return typeof value === "string" && value.length > 0 ? value : fallback; +} + +function readOptionalNumber(name: string): number | undefined { + const value = process.env[name]; + if (!value) { + return undefined; + } + + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; +} + +function normalizeSymbol(value: string): string { + return value.replace(/[^a-z0-9]/gi, "").toLowerCase(); +} + +function parseRequiredNumber( + value: number | string | undefined, + field: string, +): number { + const parsed = typeof value === "string" ? Number(value) : value; + if (!Number.isFinite(parsed)) { + throw new Error(`Pyth update is missing numeric field ${field}`); + } + + return parsed as number; +} + +function normalizeTimestampUs(value: number): number { + if (value >= 1_000_000_000_000_000) { + return Math.trunc(value); + } + + if (value >= 1_000_000_000_000) { + return Math.trunc(value * 1_000); + } + + if (value >= 1_000_000_000) { + return Math.trunc(value * 1_000_000); + } + + return Math.trunc(value); +} + +function selectBestSymbolMatch( + matches: SymbolResponse[], + query: string, +): SymbolResponse | undefined { + const normalized = normalizeSymbol(query); + const exact = matches.find((candidate) => { + const fields = [candidate.symbol, candidate.name, candidate.description].map(normalizeSymbol); + return fields.includes(normalized); + }); + + if (exact) { + return exact; + } + + return matches.find((candidate) => normalizeSymbol(candidate.symbol).includes(normalized)); +} + +function getClient(config: { + pythApiKey: string; + metadataServiceUrl: string; + priceServiceUrl: string; +}): Promise { + if (cachedClient) { + return Promise.resolve(cachedClient); + } + + if (!cachedClientPromise) { + cachedClientPromise = PythLazerClient.create({ + token: config.pythApiKey, + webSocketPoolConfig: {}, + ...(config.metadataServiceUrl ? { metadataServiceUrl: config.metadataServiceUrl } : {}), + ...(config.priceServiceUrl ? { priceServiceUrl: config.priceServiceUrl } : {}), + }) + .then((client) => { + cachedClient = client; + return client; + }) + .catch((error) => { + cachedClientPromise = null; + throw error; + }); + } + + return cachedClientPromise; +} + +async function resolvePriceFeedId( + client: PythLazerClient, + symbolQuery: string, + assetType: AssetType, +): Promise { + if (cachedResolvedPriceFeedId != null) { + return cachedResolvedPriceFeedId; + } + + const matches = await client.getSymbols({ + query: symbolQuery, + ...(assetType ? { asset_type: assetType } : {}), + }); + const match = selectBestSymbolMatch(matches, symbolQuery); + if (!match) { + throw new Error(`Unable to resolve a Pyth Lazer price feed for ${symbolQuery}`); + } + + cachedResolvedPriceFeedId = match.pyth_lazer_id; + return cachedResolvedPriceFeedId; +} + +export async function GET() { + const pythApiKey = readString("PYTH_API_KEY"); + if (!pythApiKey) { + return NextResponse.json( + { + ok: false, + error: "PYTH_API_KEY is not configured on the server runtime.", + }, + { status: 503 }, + ); + } + + try { + const symbolQuery = readString("PYTH_PRIMARY_SYMBOL_QUERY", "ADA/USD"); + const feedId = readString("PYTH_PRIMARY_FEED_ID", "pyth-ada-usd"); + const symbol = readString("PYTH_PRIMARY_SYMBOL", "ADA/USD"); + const assetType = readString("PYTH_PRIMARY_ASSET_TYPE", "crypto") as AssetType; + const priceFeedId = readOptionalNumber("PYTH_PRIMARY_PRICE_FEED_ID"); + const streamChannel = readString("PYTH_STREAM_CHANNEL", "fixed_rate@200ms") as Channel; + const metadataServiceUrl = readString("PYTH_METADATA_SERVICE_URL"); + const priceServiceUrl = readString("PYTH_PRICE_SERVICE_URL"); + + const client = await getClient({ + pythApiKey, + metadataServiceUrl, + priceServiceUrl, + }); + + let actualPriceFeedId = priceFeedId; + if (priceFeedId == null) { + actualPriceFeedId = await resolvePriceFeedId(client, symbolQuery, assetType); + } + + if (actualPriceFeedId == null) { + throw new Error(`Unable to resolve a Pyth Lazer price feed for ${symbolQuery}`); + } + + const raw = await client.getLatestPrice({ + channel: streamChannel, + priceFeedIds: [actualPriceFeedId], + properties: [ + "price", + "emaPrice", + "confidence", + "publisherCount", + "exponent", + "feedUpdateTimestamp", + ], + formats: ["solana"], + parsed: true, + jsonBinaryEncoding: "hex", + }); + + const parsed = raw.parsed?.priceFeeds.find( + (candidate) => candidate.priceFeedId === actualPriceFeedId, + ); + if (!parsed) { + throw new Error(`Pyth response did not include parsed payload for feed ${actualPriceFeedId}`); + } + + const exponent = parseRequiredNumber(parsed.exponent, "exponent"); + const feedUpdateTimestampUs = normalizeTimestampUs( + parseRequiredNumber(parsed.feedUpdateTimestamp, "feedUpdateTimestamp"), + ); + const oracle: OracleSnapshot = { + feedId, + symbol, + price: toDecimal(parseRequiredNumber(parsed.price, "price"), exponent), + emaPrice: toDecimal(parseRequiredNumber(parsed.emaPrice, "emaPrice"), exponent), + confidence: toDecimal(parseRequiredNumber(parsed.confidence, "confidence"), exponent), + publisherCount: parsed.publisherCount ?? undefined, + updatedAtMs: Math.trunc(feedUpdateTimestampUs / 1000), + }; + + return NextResponse.json({ + ok: true, + source: "pyth_live", + oracle, + }); + } catch (error) { + return NextResponse.json( + { + ok: false, + error: + error instanceof Error + ? error.message + : "Unable to fetch the live Pyth snapshot.", + }, + { status: 502 }, + ); + } +} diff --git a/lazer/cardano/guards/source/apps/ui/app/api/oracle/quotes/route.ts b/lazer/cardano/guards/source/apps/ui/app/api/oracle/quotes/route.ts new file mode 100644 index 00000000..213af2d8 --- /dev/null +++ b/lazer/cardano/guards/source/apps/ui/app/api/oracle/quotes/route.ts @@ -0,0 +1,274 @@ +import { + PythLazerClient, + type Channel, + type SymbolResponse, +} from "@pythnetwork/pyth-lazer-sdk"; +import { NextResponse } from "next/server"; +import path from "node:path"; +import { config as loadDotenv } from "dotenv"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +loadDotenv({ path: path.resolve(process.cwd(), ".env"), quiet: true }); +loadDotenv({ path: path.resolve(process.cwd(), "..", "..", ".env"), quiet: true }); + +interface QuoteRequest { + key: string; + feedId: string; + symbol: string; + symbolQuery: string; +} + +interface ParsedPriceFeed { + priceFeedId: number; + price?: number | string; + emaPrice?: number | string; + confidence?: number | string; + exponent?: number | string; + feedUpdateTimestamp?: number | string; + publisherCount?: number | null; +} + +const QUOTES: QuoteRequest[] = [ + { + key: "ada", + feedId: "pyth-ada-usd", + symbol: "ADA/USD", + symbolQuery: "ADA/USD", + }, + { + key: "xau", + feedId: "pyth-xau-usd", + symbol: "XAU/USD", + symbolQuery: "XAU/USD", + }, + { + key: "btc", + feedId: "pyth-btc-usd", + symbol: "BTC/USD", + symbolQuery: "BTC/USD", + }, + { + key: "sol", + feedId: "pyth-sol-usd", + symbol: "SOL/USD", + symbolQuery: "SOL/USD", + }, + { + key: "eur", + feedId: "pyth-eur-usd", + symbol: "EUR/USD", + symbolQuery: "EUR/USD", + }, +]; + +let cachedClientInstance: PythLazerClient | null = null; +let cachedClientPromise: Promise | null = null; +const cachedPriceFeedIds = new Map(); + +function readString(name: string, fallback = ""): string { + const value = process.env[name]; + return typeof value === "string" && value.length > 0 ? value : fallback; +} + +function normalizeSymbol(value: string): string { + return value.replace(/[^a-z0-9]/gi, "").toLowerCase(); +} + +function parseRequiredNumber( + value: number | string | undefined, + field: string, +): number { + const parsed = typeof value === "string" ? Number(value) : value; + if (!Number.isFinite(parsed)) { + throw new Error(`Pyth update is missing numeric field ${field}`); + } + + return parsed as number; +} + +function toDecimal(value: number, exponent: number): number { + return value * 10 ** exponent; +} + +function normalizeTimestampUs(value: number): number { + if (value >= 1_000_000_000_000_000) { + return Math.trunc(value); + } + + if (value >= 1_000_000_000_000) { + return Math.trunc(value * 1_000); + } + + if (value >= 1_000_000_000) { + return Math.trunc(value * 1_000_000); + } + + return Math.trunc(value); +} + +function selectBestSymbolMatch( + matches: SymbolResponse[], + query: string, +): SymbolResponse | undefined { + const normalized = normalizeSymbol(query); + const exact = matches.find((candidate) => { + const fields = [candidate.symbol, candidate.name, candidate.description].map(normalizeSymbol); + return fields.includes(normalized); + }); + if (exact) { + return exact; + } + + return matches.find((candidate) => normalizeSymbol(candidate.symbol).includes(normalized)); +} + +function findParsedFeed( + feeds: ParsedPriceFeed[] | undefined, + priceFeedId: number, +): ParsedPriceFeed { + const feed = feeds?.find((candidate) => candidate.priceFeedId === priceFeedId); + if (!feed) { + throw new Error(`Pyth response did not include parsed payload for feed ${priceFeedId}`); + } + + return feed; +} + +function getClient(): Promise { + const pythApiKey = readString("PYTH_API_KEY"); + if (!pythApiKey) { + throw new Error("PYTH_API_KEY is not configured on the server runtime."); + } + + if (cachedClientInstance) { + return Promise.resolve(cachedClientInstance); + } + + if (!cachedClientPromise) { + cachedClientPromise = PythLazerClient.create({ + token: pythApiKey, + webSocketPoolConfig: {}, + ...(readString("PYTH_METADATA_SERVICE_URL") + ? { metadataServiceUrl: readString("PYTH_METADATA_SERVICE_URL") } + : {}), + ...(readString("PYTH_PRICE_SERVICE_URL") + ? { priceServiceUrl: readString("PYTH_PRICE_SERVICE_URL") } + : {}), + }) + .then((client) => { + cachedClientInstance = client; + return client; + }) + .catch((error) => { + cachedClientPromise = null; + throw error; + }); + } + + return cachedClientPromise; +} + +async function resolvePriceFeedId( + client: PythLazerClient, + quote: QuoteRequest, +): Promise { + const cached = cachedPriceFeedIds.get(quote.symbolQuery); + if (cached != null) { + return cached; + } + + const matches = await client.getSymbols({ query: quote.symbolQuery }); + const match = selectBestSymbolMatch(matches, quote.symbolQuery); + if (!match) { + throw new Error(`Unable to resolve a Pyth Lazer price feed for ${quote.symbolQuery}`); + } + + cachedPriceFeedIds.set(quote.symbolQuery, match.pyth_lazer_id); + return match.pyth_lazer_id; +} + +export async function GET() { + if (!readString("PYTH_API_KEY")) { + return NextResponse.json( + { ok: false, error: "PYTH_API_KEY is not configured on the server runtime." }, + { status: 503 }, + ); + } + + try { + const client = await getClient(); + + const resolved = await Promise.all( + QUOTES.map(async (quote) => { + return { + ...quote, + priceFeedId: await resolvePriceFeedId(client, quote), + }; + }), + ); + + const raw = await client.getLatestPrice({ + channel: readString("PYTH_STREAM_CHANNEL", "fixed_rate@200ms") as Channel, + priceFeedIds: resolved.map((quote) => quote.priceFeedId), + properties: [ + "price", + "emaPrice", + "confidence", + "publisherCount", + "exponent", + "feedUpdateTimestamp", + ], + formats: ["solana"], + parsed: true, + jsonBinaryEncoding: "hex", + }); + + const quotes = Object.fromEntries( + resolved.map((quote) => { + const parsed = findParsedFeed(raw.parsed?.priceFeeds, quote.priceFeedId); + const exponent = parseRequiredNumber(parsed.exponent, `${quote.symbol}.exponent`); + const updatedAtUs = normalizeTimestampUs( + parseRequiredNumber(parsed.feedUpdateTimestamp, `${quote.symbol}.feedUpdateTimestamp`), + ); + + return [ + quote.key, + { + feedId: quote.feedId, + symbol: quote.symbol, + price: toDecimal(parseRequiredNumber(parsed.price, `${quote.symbol}.price`), exponent), + emaPrice: toDecimal( + parseRequiredNumber(parsed.emaPrice, `${quote.symbol}.emaPrice`), + exponent, + ), + confidence: toDecimal( + parseRequiredNumber(parsed.confidence, `${quote.symbol}.confidence`), + exponent, + ), + publisherCount: parsed.publisherCount ?? undefined, + updatedAtMs: Math.trunc(updatedAtUs / 1000), + }, + ]; + }), + ); + + return NextResponse.json({ + ok: true, + source: "pyth_live", + quotes, + }); + } catch (error) { + return NextResponse.json( + { + ok: false, + error: + error instanceof Error + ? error.message + : "Unable to fetch live quotes from Pyth.", + }, + { status: 502 }, + ); + } +} diff --git a/lazer/cardano/guards/source/apps/ui/app/dashboard/page.tsx b/lazer/cardano/guards/source/apps/ui/app/dashboard/page.tsx new file mode 100644 index 00000000..43d1bc41 --- /dev/null +++ b/lazer/cardano/guards/source/apps/ui/app/dashboard/page.tsx @@ -0,0 +1,405 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { motion, AnimatePresence } from "framer-motion"; +import { Sidebar } from "@/components/sidebar"; +import { Topbar } from "@/components/topbar"; +import { MetricCard } from "@/components/metric-card"; +import { AccountsTable } from "@/components/accounts-table"; +import { RiskLadder } from "@/components/risk-ladder"; +import { PolicyCards } from "@/components/policy-cards"; +import { ExecutionTimeline } from "@/components/execution-timeline"; +import { VaultProfilePanel } from "@/components/vault-profile-panel"; +import { SwapPanel } from "@/components/swap-panel"; +import { SimulationReplay } from "@/components/simulation-replay"; +import { AuditLog } from "@/components/audit-log"; +import { + PreprodWarningBanner, + PreprodWarningModal, +} from "@/components/preprod-warning"; +import { VaultBootstrapLab } from "@/components/vault-bootstrap-lab"; +import { ScenarioLab } from "@/components/scenario-lab"; +import { HistoricalStrategyLab } from "@/components/historical-strategy-lab"; +import { + RuntimeControlPanel, + type DashboardMode, +} from "@/components/runtime-control-panel"; +import type { RiskLadderStep } from "@/lib/types"; +import { demoState } from "@/lib/demo-data"; +import { + applyLiveQuotesToDemoState, + liveReferencePriceForSymbol, + type LiveQuoteMap, +} from "@/lib/live-prices"; +import { + connectPreferredWallet, + hydrateStoredWalletSession, + persistWalletSession, + type WalletSession, +} from "@/lib/wallet-session"; +import { + mockDatasetOptions, + type MockDatasetId, +} from "@/lib/mock-backtest"; +import { + buildBootstrapDraft, + buildPolicyViewFromDraft, +} from "@/lib/vault-lab"; + +const sectionTransition = { + initial: { opacity: 0, y: 20 }, + animate: { opacity: 1, y: 0 }, + exit: { opacity: 0, y: -10 }, + transition: { duration: 0.35, ease: "easeOut" as const }, +}; + +export default function Dashboard() { + const [activeSection, setActiveSection] = useState("overview"); + const [mode, setMode] = useState("mock"); + const [dataset, setDataset] = useState("ada_treasury_base"); + const [walletSession, setWalletSession] = useState(null); + const [connectingWallet, setConnectingWallet] = useState(false); + const [liveQuotes, setLiveQuotes] = useState(null); + const [liveQuotesError, setLiveQuotesError] = useState(null); + const [liveQuotesPollingEnabled, setLiveQuotesPollingEnabled] = useState(true); + const [bootstrapDraft, setBootstrapDraft] = useState(() => + buildBootstrapDraft(demoState.policy), + ); + + useEffect(() => { + setWalletSession(hydrateStoredWalletSession()); + }, []); + + useEffect(() => { + persistWalletSession(walletSession); + }, [walletSession]); + + useEffect(() => { + if (!liveQuotesPollingEnabled) { + return; + } + + let cancelled = false; + + async function loadQuotes() { + try { + const response = await fetch("/api/oracle/quotes", { + cache: "no-store", + }); + const payload = (await response.json()) as { + ok: boolean; + error?: string; + quotes?: LiveQuoteMap; + }; + + if (cancelled) { + return; + } + + if (!response.ok || !payload.ok || !payload.quotes) { + setLiveQuotesError(payload.error ?? "Unable to fetch live quotes."); + setLiveQuotes(null); + if (response.status === 503) { + setLiveQuotesPollingEnabled(false); + } + return; + } + + setLiveQuotes(payload.quotes); + setLiveQuotesError(null); + } catch (error) { + if (cancelled) { + return; + } + + setLiveQuotesError( + error instanceof Error ? error.message : "Unable to fetch live quotes.", + ); + setLiveQuotes(null); + } + } + + void loadQuotes(); + const interval = window.setInterval(() => { + void loadQuotes(); + }, 60_000); + + return () => { + cancelled = true; + window.clearInterval(interval); + }; + }, [liveQuotesPollingEnabled]); + + useEffect(() => { + const nextReferencePrice = liveReferencePriceForSymbol( + liveQuotes ?? {}, + bootstrapDraft.referenceSymbol, + { + maxStaleUs: demoState.policy.maxStaleUs, + maxConfidenceBps: demoState.policy.maxConfidenceBps, + }, + ); + if (!nextReferencePrice || nextReferencePrice === bootstrapDraft.referencePrice) { + return; + } + + setBootstrapDraft((current) => ({ + ...current, + referencePrice: nextReferencePrice, + })); + }, [bootstrapDraft.referencePrice, bootstrapDraft.referenceSymbol, liveQuotes]); + + const data = useMemo( + () => applyLiveQuotesToDemoState(demoState, liveQuotes ?? {}), + [liveQuotes], + ); + const policyView = buildPolicyViewFromDraft(bootstrapDraft); + const datasetLabel = + mockDatasetOptions.find((option) => option.id === dataset)?.label ?? dataset; + const stablePosition = data.positions.find((p) => p.role === "stable"); + const riskPosition = data.positions.find((p) => p.role === "risk"); + const ladderStep: RiskLadderStep = data.vault.ladderStep ?? data.vault.stage; + + async function handleConnectWallet() { + if (connectingWallet) { + return; + } + + if (data.vault.chain === "evm") { + if (typeof window !== "undefined") { + window.alert( + "EVM wallet connect is not enabled in this UI yet. Use Cardano or SVM mode for the current demo.", + ); + } + return; + } + + setConnectingWallet(true); + try { + const chain = data.vault.chain === "svm" ? "svm" : "cardano"; + const session = await connectPreferredWallet(chain); + setWalletSession(session); + } finally { + setConnectingWallet(false); + } + } + + return ( +
+ + {/* Ambient background gradient */} +
+ + + +
+ setWalletSession(null)} + /> +
+ +
+ + {activeSection === "runtime" && ( + + + + )} + {/* Overview */} + {(activeSection === "overview" || activeSection === "accounts") && ( + + {activeSection === "overview" && ( + <> + {/* Hero Metrics */} +
+ + + 700 + ? "red" + : data.metrics.drawdownBps > 300 + ? "yellow" + : "default" + } + /> + +
+ + {/* Oracle Details */} + +
+
+
+

+ Oracle Feed +

+

+ {data.oracle.feedId} +

+
+ + + + + + Live + +
+
+ {[ + { label: "Spot Price", value: `$${data.oracle.price.toFixed(4)}` }, + { label: "EMA Price", value: `$${data.oracle.emaPrice.toFixed(4)}` }, + { label: "Confidence", value: `±$${data.oracle.confidence.toFixed(4)}` }, + { label: "Symbol", value: data.oracle.symbol, accent: true }, + ].map((item) => ( +
+

{item.label}

+

+ {item.value} +

+
+ ))} +
+ + + )} + + + + )} + + {/* Policy */} + {activeSection === "policy" && ( + + + + + )} + + {activeSection === "runtime" && ( + + )} + {/* Risk Ladder */} + {activeSection === "risk" && ( + + {mode === "mock" ? ( + + ) : ( + + )} + + + + )} + + {/* Execution */} + {activeSection === "execution" && ( + + + + + )} + + {/* Swap */} + {activeSection === "swap" && ( + + + + )} + + {/* Audit */} + {activeSection === "audit" && ( + + + + )} + +
+
+ ); +} diff --git a/lazer/cardano/guards/source/apps/ui/app/globals.css b/lazer/cardano/guards/source/apps/ui/app/globals.css new file mode 100644 index 00000000..4d34cb75 --- /dev/null +++ b/lazer/cardano/guards/source/apps/ui/app/globals.css @@ -0,0 +1,227 @@ +@import "tailwindcss"; + +@theme { + /* Guards Design System — Ultra Premium */ + --color-bg: #070612; + --color-bg-soft: #0c0b18; + --color-panel: #0f0e1c; + --color-panel-hover: #14132a; + --color-panel-active: #1a1833; + --color-surface: #121120; + --color-line: #1e1d30; + --color-line-soft: #16152a; + + --color-text: #f0eef8; + --color-text-secondary: #9896aa; + --color-text-muted: #5e5c72; + + --color-accent: #7c6ff7; + --color-accent-hover: #9b8fff; + --color-accent-muted: #7c6ff718; + --color-accent-dim: #7c6ff70c; + + --color-blue: #3b82f6; + --color-blue-muted: #3b82f618; + + --color-green: #22c55e; + --color-green-muted: #22c55e14; + --color-yellow: #f0bf5f; + --color-yellow-muted: #f0bf5f14; + --color-red: #ef6f6c; + --color-red-muted: #ef6f6c14; + + --font-sans: "Manrope", system-ui, sans-serif; + --font-mono: "IBM Plex Mono", "SF Mono", monospace; +} + +@layer base { + * { + border-color: var(--color-line); + } + + body { + background-color: var(--color-bg); + color: var(--color-text); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + font-feature-settings: "cv02", "cv03", "cv04", "cv11"; + } + + ::-webkit-scrollbar { + width: 5px; + height: 5px; + } + ::-webkit-scrollbar-track { + background: transparent; + } + ::-webkit-scrollbar-thumb { + background: #1e1d30; + border-radius: 3px; + } + ::-webkit-scrollbar-thumb:hover { + background: #7c6ff730; + } +} + +/* === Glass Panels === */ +.glass-panel { + background: linear-gradient(135deg, rgba(15, 14, 28, 0.85), rgba(12, 11, 24, 0.9)); + backdrop-filter: blur(32px); + border: 1px solid var(--color-line); + border-radius: 1.25rem; +} +.glass-panel:hover { + border-color: #7c6ff718; +} + +.glass-sidebar { + background: linear-gradient(180deg, rgba(12, 11, 24, 0.95), rgba(7, 6, 18, 0.98)); + backdrop-filter: blur(48px); + border-right: 1px solid var(--color-line); +} + +/* === Chips === */ +.chip { + display: inline-flex; + align-items: center; + gap: 0.375rem; + padding: 0.25rem 0.75rem; + border-radius: 9999px; + font-size: 0.7rem; + font-weight: 500; + font-family: var(--font-mono); + letter-spacing: 0.03em; +} +.chip-accent { + display: inline-flex; + align-items: center; + gap: 0.375rem; + padding: 0.25rem 0.75rem; + border-radius: 9999px; + font-size: 0.7rem; + font-weight: 500; + font-family: var(--font-mono); + letter-spacing: 0.03em; + background: var(--color-accent-muted); + color: var(--color-accent); +} +.chip-blue { + display: inline-flex; align-items: center; gap: 0.375rem; padding: 0.25rem 0.75rem; + border-radius: 9999px; font-size: 0.7rem; font-weight: 500; + font-family: var(--font-mono); letter-spacing: 0.03em; + background: var(--color-blue-muted); color: var(--color-blue); +} +.chip-green { + display: inline-flex; align-items: center; gap: 0.375rem; padding: 0.25rem 0.75rem; + border-radius: 9999px; font-size: 0.7rem; font-weight: 500; + font-family: var(--font-mono); letter-spacing: 0.03em; + background: var(--color-green-muted); color: var(--color-green); +} +.chip-yellow { + display: inline-flex; align-items: center; gap: 0.375rem; padding: 0.25rem 0.75rem; + border-radius: 9999px; font-size: 0.7rem; font-weight: 500; + font-family: var(--font-mono); letter-spacing: 0.03em; + background: var(--color-yellow-muted); color: var(--color-yellow); +} +.chip-red { + display: inline-flex; align-items: center; gap: 0.375rem; padding: 0.25rem 0.75rem; + border-radius: 9999px; font-size: 0.7rem; font-weight: 500; + font-family: var(--font-mono); letter-spacing: 0.03em; + background: var(--color-red-muted); color: var(--color-red); +} + +/* === Typography === */ +.eyebrow { + font-size: 0.65rem; + font-family: var(--font-mono); + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--color-text-muted); +} +.metric-value { + font-size: 1.625rem; + font-weight: 700; + letter-spacing: -0.03em; + color: var(--color-text); +} + +/* === Interactive === */ +.card-hover { + transition: all 250ms cubic-bezier(0.4, 0, 0.2, 1); +} +.card-hover:hover { + background: linear-gradient(135deg, rgba(20, 19, 42, 0.9), rgba(15, 14, 28, 0.9)); + border-color: #7c6ff720; + box-shadow: 0 12px 40px rgba(124, 111, 247, 0.06), 0 2px 12px rgba(0, 0, 0, 0.3); + transform: translateY(-1px); +} + +.nav-item { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.625rem 1rem; + border-radius: 0.875rem; + font-size: 0.85rem; + font-weight: 500; + color: var(--color-text-secondary); + transition: all 200ms cubic-bezier(0.4, 0, 0.2, 1); + cursor: pointer; + border: 1px solid transparent; +} +.nav-item:hover { + background: var(--color-panel-hover); + color: var(--color-text); + border-color: var(--color-line); +} +.nav-item.active { + background: var(--color-accent-muted); + color: var(--color-accent); + border-color: #7c6ff720; + box-shadow: 0 0 20px rgba(124, 111, 247, 0.08); +} + +.status-dot { + display: inline-block; + width: 0.5rem; + height: 0.5rem; + border-radius: 9999px; +} + +.btn-primary { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.625rem 1.5rem; + background: linear-gradient(135deg, #7c6ff7, #6366f1); + color: white; + font-size: 0.85rem; + font-weight: 600; + border-radius: 0.875rem; + transition: all 200ms cubic-bezier(0.4, 0, 0.2, 1); + cursor: pointer; + box-shadow: 0 4px 16px rgba(124, 111, 247, 0.25); +} +.btn-primary:hover { + background: linear-gradient(135deg, #9b8fff, #818cf8); + box-shadow: 0 6px 24px rgba(124, 111, 247, 0.35); + transform: translateY(-1px); +} + +.btn-ghost { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 1rem; + color: var(--color-text-secondary); + font-size: 0.85rem; + font-weight: 500; + border-radius: 0.875rem; + transition: all 200ms cubic-bezier(0.4, 0, 0.2, 1); + cursor: pointer; +} +.btn-ghost:hover { + color: var(--color-text); + background: var(--color-panel-hover); +} diff --git a/lazer/cardano/guards/source/apps/ui/app/layout.tsx b/lazer/cardano/guards/source/apps/ui/app/layout.tsx new file mode 100644 index 00000000..ab2fece9 --- /dev/null +++ b/lazer/cardano/guards/source/apps/ui/app/layout.tsx @@ -0,0 +1,37 @@ +import type { Metadata } from "next"; +import "./globals.css"; + +export const metadata: Metadata = { + title: "GUARDS | Treasury Autopilot", + description: + "Oracle-aware treasury policy enforcement. Protect your DAO treasury with automated risk management.", + icons: { + icon: "/guards-icon.svg", + shortcut: "/guards-icon.svg", + apple: "/guards-icon.svg", + }, +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + + + + + + {children} + + ); +} diff --git a/lazer/cardano/guards/source/apps/ui/app/page.tsx b/lazer/cardano/guards/source/apps/ui/app/page.tsx new file mode 100644 index 00000000..25bba75b --- /dev/null +++ b/lazer/cardano/guards/source/apps/ui/app/page.tsx @@ -0,0 +1,21 @@ +import { Navbar } from "@/components/landing/navbar"; +import { Hero } from "@/components/landing/hero"; +import { HowItWorks } from "@/components/landing/how-it-works"; +import { MultichainSection } from "@/components/landing/multichain-section"; +import { PythBanner } from "@/components/landing/pyth-banner"; +import { CTASection } from "@/components/landing/cta-section"; +import { Footer } from "@/components/landing/footer"; + +export default function LandingPage() { + return ( +
+ + + + + + +
+
+ ); +} diff --git a/lazer/cardano/guards/source/apps/ui/components/accounts-table.tsx b/lazer/cardano/guards/source/apps/ui/components/accounts-table.tsx new file mode 100644 index 00000000..5408b3ab --- /dev/null +++ b/lazer/cardano/guards/source/apps/ui/components/accounts-table.tsx @@ -0,0 +1,96 @@ +import { TreasuryPosition } from "@/lib/types"; + +interface AccountsTableProps { + positions: TreasuryPosition[]; +} + +export function AccountsTable({ positions }: AccountsTableProps) { + const total = positions.reduce((s, p) => s + p.fiatValue, 0); + + return ( +
+
+

Treasury Accounts

+
+ + + + + + + + + + + + {positions.map((pos) => ( + + + + + + + + ))} + + + + + + + + +
AssetRoleBalanceFiat ValueWeight
+
+
+ {pos.symbol.slice(0, 2)} +
+ + {pos.symbol} + +
+
+ + {pos.role === "risk" ? "Hot Risk" : "Stable Reserve"} + + + {pos.amount.toLocaleString()} + + ${pos.fiatValue.toLocaleString()} + +
+
+
+
+ + {(pos.weight * 100).toFixed(0)}% + +
+
+ Total + + ${total.toLocaleString()} + + 100% +
+
+ ); +} diff --git a/lazer/cardano/guards/source/apps/ui/components/audit-log.tsx b/lazer/cardano/guards/source/apps/ui/components/audit-log.tsx new file mode 100644 index 00000000..a5f2f344 --- /dev/null +++ b/lazer/cardano/guards/source/apps/ui/components/audit-log.tsx @@ -0,0 +1,60 @@ +import { ExecutionEvent } from "@/lib/types"; + +interface AuditLogProps { + events: ExecutionEvent[]; +} + +function timeFormat(timestamp: number) { + return new Date(timestamp).toLocaleString("en-US", { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + }); +} + +export function AuditLog({ events }: AuditLogProps) { + return ( +
+
+

Audit Log

+

+ Immutable execution record with oracle evidence +

+
+
+ {events.map((evt) => ( +
+
+ +
+
+

+ + {evt.kind === "derisk_swap" ? "De-Risk" : "Re-Entry"} + {" "} + — {evt.amount.toLocaleString()} {evt.sourceSymbol} → {evt.destinationSymbol} +

+

+ Stage: {evt.stage} · Route: {evt.route} · {evt.id} +

+
+ + {timeFormat(evt.timestamp)} + +
+ ))} +
+
+ ); +} diff --git a/lazer/cardano/guards/source/apps/ui/components/execution-timeline.tsx b/lazer/cardano/guards/source/apps/ui/components/execution-timeline.tsx new file mode 100644 index 00000000..b34bde88 --- /dev/null +++ b/lazer/cardano/guards/source/apps/ui/components/execution-timeline.tsx @@ -0,0 +1,114 @@ +import { ExecutionEvent } from "@/lib/types"; + +interface ExecutionTimelineProps { + events: ExecutionEvent[]; + referenceNowMs: number; +} + +function statusChip(status: ExecutionEvent["status"]) { + switch (status) { + case "executed": + return Executed; + case "pending": + return Pending; + case "failed": + return Failed; + } +} + +function kindLabel(kind: ExecutionEvent["kind"]) { + return kind === "derisk_swap" ? "De-Risk Swap" : "Re-Entry Swap"; +} + +function timeAgo(timestamp: number, referenceNowMs: number) { + const diff = referenceNowMs - timestamp; + const mins = Math.floor(diff / 60000); + if (mins < 1) return "Just now"; + if (mins < 60) return `${mins}m ago`; + const hours = Math.floor(mins / 60); + if (hours < 24) return `${hours}h ago`; + return `${Math.floor(hours / 24)}d ago`; +} + +export function ExecutionTimeline({ + events, + referenceNowMs, +}: ExecutionTimelineProps) { + return ( +
+
+
+

+ Execution Timeline +

+

+ Recent swap executions and pending intents +

+
+ {events.length} events +
+
+ {events.map((evt) => ( +
+ {/* Icon */} +
+ + {evt.kind === "derisk_swap" ? ( + <> + + + + ) : ( + <> + + + + )} + +
+ {/* Details */} +
+
+ + {kindLabel(evt.kind)} + + {statusChip(evt.status)} +
+

+ {evt.amount.toLocaleString()} {evt.sourceSymbol} →{" "} + {evt.destinationSymbol} via {evt.route} +

+
+ {/* Time */} +
+

+ {timeAgo(evt.timestamp, referenceNowMs)} +

+

+ {evt.id} +

+
+
+ ))} +
+
+ ); +} diff --git a/lazer/cardano/guards/source/apps/ui/components/historical-strategy-lab.tsx b/lazer/cardano/guards/source/apps/ui/components/historical-strategy-lab.tsx new file mode 100644 index 00000000..7a858568 --- /dev/null +++ b/lazer/cardano/guards/source/apps/ui/components/historical-strategy-lab.tsx @@ -0,0 +1,236 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { Play, TrendingUp } from "lucide-react"; +import { getStageAppearance } from "@/lib/stage"; +import { + mockDatasetOptions, + mockStrategyOptions, + runMockBacktest, + type MockDatasetId, + type MockBacktestPoint, + type MockStrategyId, +} from "@/lib/mock-backtest"; +import type { VaultBootstrapDraft } from "@/lib/vault-lab"; + +interface HistoricalStrategyLabProps { + draft: VaultBootstrapDraft; + dataset: MockDatasetId; +} + +function Chart({ + points, + activeIndex, + setActiveIndex, +}: { + points: MockBacktestPoint[]; + activeIndex: number; + setActiveIndex: (index: number) => void; +}) { + const width = 720; + const height = 180; + const maxPrice = Math.max(...points.map((point) => point.adaPrice)); + const minPrice = Math.min(...points.map((point) => point.adaPrice)); + const range = Math.max(maxPrice - minPrice, 0.01); + const stepX = width / Math.max(points.length - 1, 1); + const line = points + .map((point, index) => { + const normalizedY = height - ((point.adaPrice - minPrice) / range) * height; + return `${index * stepX},${normalizedY}`; + }) + .join(" "); + + return ( + + {[0, 0.25, 0.5, 0.75, 1].map((ratio) => ( + + ))} + + {points.map((point, index) => { + const y = height - ((point.adaPrice - minPrice) / range) * height; + const active = index === activeIndex; + const color = getStageAppearance(point.stage).chartColor; + return ( + setActiveIndex(index)} + /> + ); + })} + + ); +} + +export function HistoricalStrategyLab({ draft, dataset }: HistoricalStrategyLabProps) { + const [strategy, setStrategy] = useState("guards_ladder"); + const [activeIndex, setActiveIndex] = useState(0); + const [autoplay, setAutoplay] = useState(false); + + const result = useMemo( + () => + runMockBacktest(draft, { + strategy, + dataset, + days: 7, + intervalMinutes: 15, + referenceSymbol: draft.referenceSymbol, + }), + [dataset, draft, strategy], + ); + + const activePoint = result.points[activeIndex] ?? result.points[0]; + const appearance = getStageAppearance(activePoint?.stage ?? "normal"); + + useEffect(() => { + if (!autoplay || typeof window === "undefined") { + return undefined; + } + + const timer = window.setInterval(() => { + setActiveIndex((current) => (current + 1) % result.points.length); + }, 220); + + return () => window.clearInterval(timer); + }, [autoplay, result.points.length]); + + return ( +
+
+
+
+

Mock replay lab

+

+ Seven days of 15-minute price states. Execute the strategy engine repeatedly and inspect how stages and swaps evolve. +

+
+
+ 7d + 15m + + Dataset: {mockDatasetOptions.find((option) => option.id === dataset)?.label ?? dataset} + + +
+
+
+ +
+
+
+ {mockStrategyOptions.map((option) => ( + + ))} +
+ +
+ + setActiveIndex(Number(event.target.value))} + className="mt-3 w-full accent-[#7c6ff7]" + /> +
+
+ +
+
+
+
+ + Backtest summary +
+ {appearance.label} +
+
+
+

Executions

+

{result.summary.executionCount}

+
+
+

Min liquid value

+

${result.summary.minLiquidValueFiat.toLocaleString()}

+
+
+

Final stable ratio

+

{(result.summary.finalStableRatio * 100).toFixed(1)}%

+
+
+

Last point

+

{activePoint?.label}

+
+
+
+ +
+
+

Active point

+

ADA ${activePoint?.adaPrice.toFixed(4)}

+

EMA ${activePoint?.adaEmaPrice.toFixed(4)} · confidence ±${activePoint?.adaConfidence.toFixed(4)}

+
+
+

Strategy trigger

+

{activePoint?.trigger}

+
+
+

Execution

+

+ {activePoint?.executionLabel ?? "No execution at this step."} +

+
+
+

Reference asset ({draft.referenceSymbol})

+

${activePoint?.referencePrice.toFixed(2)}

+
+
+
+
+
+ ); +} diff --git a/lazer/cardano/guards/source/apps/ui/components/landing/animations.tsx b/lazer/cardano/guards/source/apps/ui/components/landing/animations.tsx new file mode 100644 index 00000000..480c4953 --- /dev/null +++ b/lazer/cardano/guards/source/apps/ui/components/landing/animations.tsx @@ -0,0 +1,90 @@ +"use client"; + +import { motion } from "framer-motion"; +import { ReactNode } from "react"; + +interface BlurInProps { + children: ReactNode; + delay?: number; + duration?: number; + className?: string; +} + +export function BlurIn({ + children, + delay = 0, + duration = 0.6, + className = "", +}: BlurInProps) { + return ( + + {children} + + ); +} + +interface SplitTextProps { + text: string; + className?: string; + wordDelay?: number; + duration?: number; +} + +export function SplitText({ + text, + className = "", + wordDelay = 0.08, + duration = 0.6, +}: SplitTextProps) { + const words = text.split(" "); + return ( + + {words.map((word, i) => ( + + {word} + + ))} + + ); +} + +interface FadeUpProps { + children: ReactNode; + delay?: number; + duration?: number; + className?: string; +} + +export function FadeUp({ + children, + delay = 0, + duration = 0.7, + className = "", +}: FadeUpProps) { + return ( + + {children} + + ); +} diff --git a/lazer/cardano/guards/source/apps/ui/components/landing/cta-section.tsx b/lazer/cardano/guards/source/apps/ui/components/landing/cta-section.tsx new file mode 100644 index 00000000..afd88d8e --- /dev/null +++ b/lazer/cardano/guards/source/apps/ui/components/landing/cta-section.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { ArrowRight } from "lucide-react"; +import { FadeUp } from "./animations"; + +export function CTASection() { + return ( +
+ {/* Gradient background */} +
+ +
+ +

+ Stop watching your treasury. +
+ + Start protecting it. + +

+
+ + +

+ Define policy bounds once, then let the execution flow react automatically + when the treasury breaches protected thresholds. +

+
+ + + + +
+
+ ); +} diff --git a/lazer/cardano/guards/source/apps/ui/components/landing/footer.tsx b/lazer/cardano/guards/source/apps/ui/components/landing/footer.tsx new file mode 100644 index 00000000..d7d7e1fd --- /dev/null +++ b/lazer/cardano/guards/source/apps/ui/components/landing/footer.tsx @@ -0,0 +1,42 @@ +export function Footer() { + return ( +
+
+
+
+
+ Guards +
+

+ Oracle-aware treasury control plane for policy-driven, bounded execution. +

+
+ + +
+ +
+ Powered by Pyth price feeds. Cardano-first submission with multichain policy design. + guards.one +
+
+
+ ); +} diff --git a/lazer/cardano/guards/source/apps/ui/components/landing/hero.tsx b/lazer/cardano/guards/source/apps/ui/components/landing/hero.tsx new file mode 100644 index 00000000..0e429872 --- /dev/null +++ b/lazer/cardano/guards/source/apps/ui/components/landing/hero.tsx @@ -0,0 +1,94 @@ +"use client"; + +import { Sparkles, ArrowRight } from "lucide-react"; +import { BlurIn, SplitText } from "./animations"; +import { VideoBackground } from "./video-background"; + +export function Hero() { + return ( +
+ {/* Video */} + + + {/* Bottom gradient fade */} +
+ + {/* Content */} +
+
+
+ {/* Badge + Heading + Subtitle */} +
+ {/* Badge */} + +
+ + + For DAO and protocol treasuries + +
+
+ + {/* Heading */} +

+ +
+ +
+ + + {" "} + + + +

+ + {/* Subtitle */} + +

+ Guards automates treasury protection with Pyth-driven policy checks + and pre-approved execution bounds before a protected fiat or stable + floor is breached. +

+
+
+ + {/* CTA Buttons */} + + + +
+
+
+
+ ); +} diff --git a/lazer/cardano/guards/source/apps/ui/components/landing/how-it-works.tsx b/lazer/cardano/guards/source/apps/ui/components/landing/how-it-works.tsx new file mode 100644 index 00000000..8430588c --- /dev/null +++ b/lazer/cardano/guards/source/apps/ui/components/landing/how-it-works.tsx @@ -0,0 +1,389 @@ +"use client"; + +import { useState } from "react"; +import { motion, AnimatePresence, useReducedMotion } from "framer-motion"; +import { FadeUp } from "./animations"; + +const steps = [ + { + number: "01", + title: "Observe", + description: + "Read Pyth price, EMA, confidence, and freshness for the protected asset and any reference asset.", + detail: + "The collector polls Pyth oracle feeds and caches snapshots. Every data point is timestamped and validated for freshness before reaching the engine.", + accent: "#7c6ff7", + icon: ( + + + + + + ), + }, + { + number: "02", + title: "Evaluate", + description: + "Evaluate drawdown, liquid value floors, and oracle quality against the treasury policy ladder.", + detail: + "The risk engine computes drawdown (spot vs EMA), checks absolute fiat floors, and validates oracle confidence. The result determines the next stage on the risk ladder.", + accent: "#3b82f6", + icon: ( + + + + + ), + }, + { + number: "03", + title: "Authorize", + description: + "Authorize a bounded execution intent with approved route, max size, and oracle evidence.", + detail: + "Every intent carries snapshot IDs, a reason hash, and expiry. It can only execute through governance-approved routes with capped volume. No open-ended permissions.", + accent: "#22c55e", + icon: ( + + + + + ), + }, + { + number: "04", + title: "Execute", + description: + "Execute automatically inside governance-approved route and volume limits, then record for audit.", + detail: + "The keeper swaps from the bounded execution bucket through DexHunter or the fallback venue. Every result is anchored with oracle evidence for full traceability.", + accent: "#f0bf5f", + icon: ( + + + + ), + }, +]; + +const ladderStages = [ + { + name: "Normal", + trigger: "Drawdown < watch threshold", + action: "Hold allocation, monitor oracle feeds continuously.", + color: "#22c55e", + }, + { + name: "Watch", + trigger: "Drawdown exceeds watch band", + action: "Increase monitoring frequency. Prepare execution routes and validate venue liquidity.", + color: "#f0bf5f", + }, + { + name: "Partial De-Risk", + trigger: "Drawdown exceeds partial threshold", + action: "Swap a portion of the risk asset into the approved stable to rebuild the protected floor.", + color: "#ef6f6c", + }, + { + name: "Full Stable Exit", + trigger: "Floor breached or drawdown critical", + action: "Emergency exit: move all remaining risk exposure into stable. Fiat floor is the priority.", + color: "#ef4444", + }, + { + name: "Frozen", + trigger: "Oracle stale or confidence too wide", + action: "All execution halted. The engine does not trade on unreliable data. Resumes when oracle quality recovers.", + color: "#9896aa", + }, + { + name: "Auto Re-Entry", + trigger: "Recovery confirmed + cooldown elapsed", + action: "Gradually restore risk allocation. Hysteresis prevents oscillation — a single tick is not enough.", + color: "#7c6ff7", + }, +]; + +export function HowItWorks() { + const [activeStep, setActiveStep] = useState(0); + const [activeLadder, setActiveLadder] = useState(null); + const reduceMotion = useReducedMotion(); + + return ( +
+
+ +

+ How It Works +

+

+ Oracle signal. +
+ Policy ladder. +
+ Bounded execution. +

+

+ Guards is not an arbitrary trading bot. Governance defines the policy, + the approved routes, and the volume limits. Automation only executes inside + that envelope. +

+
+ + {/* Interactive Steps */} +
+ {/* Left: Step selector */} +
+ {steps.map((step, i) => ( + setActiveStep(i)} + initial={{ opacity: 0, x: -20 }} + whileInView={{ opacity: 1, x: 0 }} + viewport={{ once: true }} + transition={ + reduceMotion ? { duration: 0 } : { delay: i * 0.1, duration: 0.4 } + } + aria-pressed={activeStep === i} + aria-label={`Select step ${step.number}: ${step.title}`} + className={`w-full text-left p-5 rounded-2xl border transition-all duration-300 cursor-pointer group ${ + activeStep === i + ? "border-white/10 bg-white/[0.04]" + : "border-transparent hover:border-white/5 hover:bg-white/[0.02]" + }`} + > +
+
+ {step.icon} +
+
+
+ + {step.number} + +

+ {step.title} +

+
+

+ {step.description} +

+
+
+
+ ))} +
+ + {/* Right: Active step detail */} +
+ + + {/* Glow */} +
+ +
+
+
+ {steps[activeStep].icon} +
+
+ + Step {steps[activeStep].number} + +

+ {steps[activeStep].title} +

+
+
+ +

+ {steps[activeStep].detail} +

+ + {/* Progress indicator */} +
+ {steps.map((step, i) => ( + + ))} +
+
+ + +
+
+ + {/* Risk Ladder - Expanded */} + +
+
+

+ Risk Ladder +

+

+ Six stages. Zero ambiguity. +

+

+ The risk ladder is a deterministic state machine — not a set of alerts. + Each stage has a precise trigger and a defined action. The engine escalates + and de-escalates automatically based on oracle signals, and freezes execution + when data quality degrades. +

+
+ +
+ {ladderStages.map((stage, i) => ( + + + + + {activeLadder === i && ( + +
+
+
+

+ Trigger +

+

+ {stage.trigger} +

+
+
+

+ Action +

+

+ {stage.action} +

+
+
+
+
+ )} +
+
+ ))} +
+
+
+
+
+ ); +} diff --git a/lazer/cardano/guards/source/apps/ui/components/landing/multichain-section.tsx b/lazer/cardano/guards/source/apps/ui/components/landing/multichain-section.tsx new file mode 100644 index 00000000..dc52c4ac --- /dev/null +++ b/lazer/cardano/guards/source/apps/ui/components/landing/multichain-section.tsx @@ -0,0 +1,102 @@ +"use client"; + +import { motion, useReducedMotion } from "framer-motion"; +import { FadeUp } from "./animations"; + +const chains = [ + { + name: "Cardano", + src: "/chain-cardano.png", + sizeClass: "h-12 md:h-14", + width: 200, + height: 56, + }, + { + name: "Solana", + src: "/chain-solana.png", + sizeClass: "h-12 md:h-14", + width: 200, + height: 56, + }, + { + name: "Ethereum", + src: "/chain-ethereum.svg", + sizeClass: "h-[5.625rem] md:h-[6.5rem]", + width: 220, + height: 72, + }, +]; + +// Duplicate for infinite scroll illusion +const marqueeChains = [...chains, ...chains, ...chains, ...chains]; + +export function MultichainSection() { + const prefersReducedMotion = useReducedMotion(); + const displayedChains = prefersReducedMotion ? chains : marqueeChains; + + return ( +
+
+ +
+

+ Multichain Native +

+

+ One policy engine. +
+ Every chain. +

+
+
+
+ + {/* Logo Carousel */} +
+
+
+ + + {displayedChains.map((chain, i) => ( +
+ {chain.name} +
+ ))} +
+
+
+ ); +} diff --git a/lazer/cardano/guards/source/apps/ui/components/landing/navbar.tsx b/lazer/cardano/guards/source/apps/ui/components/landing/navbar.tsx new file mode 100644 index 00000000..6d59acbf --- /dev/null +++ b/lazer/cardano/guards/source/apps/ui/components/landing/navbar.tsx @@ -0,0 +1,127 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { ArrowRight, Menu, X } from "lucide-react"; +import { motion, AnimatePresence } from "framer-motion"; + +const links = [ + { href: "#how-it-works", label: "How It Works" }, + { href: "#multichain", label: "Multichain" }, + { href: "/dashboard", label: "Demo" }, +]; + +export function Navbar() { + const [scrolled, setScrolled] = useState(false); + const [mobileOpen, setMobileOpen] = useState(false); + + useEffect(() => { + const onScroll = () => setScrolled(window.scrollY > 40); + window.addEventListener("scroll", onScroll, { passive: true }); + onScroll(); + return () => window.removeEventListener("scroll", onScroll); + }, []); + + return ( + <> + +
+
+ {/* Logo */} + + Guards + + + {/* Desktop Links */} +
+ {links.map((link) => ( + + {link.label} + + + ))} +
+ + {/* Desktop CTA */} + + + {/* Mobile menu button */} + +
+
+
+ + {/* Mobile menu overlay */} + + {mobileOpen && ( + +
+ {links.map((link, i) => ( + setMobileOpen(false)} + initial={{ opacity: 0, y: 20 }} + animate={{ opacity: 1, y: 0 }} + transition={{ delay: i * 0.1 }} + className="text-white/70 text-2xl font-medium hover:text-white transition-colors" + > + {link.label} + + ))} + + Open Demo + + +
+
+ )} +
+ + ); +} diff --git a/lazer/cardano/guards/source/apps/ui/components/landing/pyth-banner.tsx b/lazer/cardano/guards/source/apps/ui/components/landing/pyth-banner.tsx new file mode 100644 index 00000000..0d8539d9 --- /dev/null +++ b/lazer/cardano/guards/source/apps/ui/components/landing/pyth-banner.tsx @@ -0,0 +1,64 @@ +"use client"; + +import { motion } from "framer-motion"; +import { FadeUp } from "./animations"; + +export function PythBanner() { + return ( +
+
+
+ +
+ +

+ Powered by +

+
+ + + + + + +

+ Real-time oracle data behind each risk signal, threshold check, and + bounded execution decision. +

+
+
+ +
+
+ ); +} diff --git a/lazer/cardano/guards/source/apps/ui/components/landing/pyth-section.tsx b/lazer/cardano/guards/source/apps/ui/components/landing/pyth-section.tsx new file mode 100644 index 00000000..260e0773 --- /dev/null +++ b/lazer/cardano/guards/source/apps/ui/components/landing/pyth-section.tsx @@ -0,0 +1,117 @@ +"use client"; + +import { FadeUp } from "./animations"; + +const signals = [ + { + signal: "price", + usage: "Spot valuation for liquid value calculation", + icon: ( + + + + ), + }, + { + signal: "emaPrice", + usage: "Baseline for drawdown measurement — spot vs exponential moving average", + icon: ( + + + + ), + }, + { + signal: "confidence", + usage: "Widening interval triggers Frozen state — blocks all execution", + icon: ( + + + + + ), + }, + { + signal: "freshness", + usage: "Stale data halts the engine — no blind execution ever", + icon: ( + + + + + ), + }, +]; + +export function PythSection() { + return ( +
+
+ +
+
+ {/* Left: Oracle signals */} + +
+ {signals.map((s, i) => ( + +
+
+ {s.icon} +
+
+

+ {s.signal} +

+

+ {s.usage} +

+
+
+
+ ))} +
+
+ + {/* Right: Copy */} + +
+
+

+ Powered by +

+
+
+ + + PYTH + + +

+ Network +

+
+

+ Oracle data is not decorative. +
+ It drives execution. +

+

+ Every execution intent carries oracle snapshot IDs. Every + decision can be independently verified. Guards doesn't just + read prices — it reasons about data quality, confidence, and + staleness before authorizing a single swap. +

+
+
+
+
+
+ ); +} diff --git a/lazer/cardano/guards/source/apps/ui/components/landing/video-background.tsx b/lazer/cardano/guards/source/apps/ui/components/landing/video-background.tsx new file mode 100644 index 00000000..ee5c412b --- /dev/null +++ b/lazer/cardano/guards/source/apps/ui/components/landing/video-background.tsx @@ -0,0 +1,66 @@ +"use client"; + +import type Hls from "hls.js"; +import { useEffect, useRef } from "react"; + +export function VideoBackground() { + const videoRef = useRef(null); + const hlsRef = useRef(null); + + useEffect(() => { + const video = videoRef.current; + if (!video) { + return; + } + + const src = "https://stream.mux.com/s8pMcOvMQXc4GD6AX4e1o01xFogFxipmuKltNfSYza0200.m3u8"; + let mounted = true; + + if (video.canPlayType("application/vnd.apple.mpegurl")) { + video.src = src; + } else { + import("hls.js").then(({ default: Hls }) => { + if (!mounted || !videoRef.current || !Hls.isSupported()) { + return; + } + + const hls = new Hls({ enableWorker: false }); + hlsRef.current = hls; + hls.loadSource(src); + hls.attachMedia(videoRef.current); + }); + } + + return () => { + mounted = false; + + if (hlsRef.current) { + hlsRef.current.destroy(); + hlsRef.current = null; + } + + const currentVideo = videoRef.current; + if (currentVideo) { + currentVideo.pause(); + currentVideo.removeAttribute("src"); + currentVideo.load(); + } + }; + }, []); + + return ( +