diff --git a/.env.example b/.env.example index a23aebb..3effd9b 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,5 @@ -# XRPL Testnet Accounts -# Get from faucet: https://xrpl.org/xrp-testnet-faucet.html -XRPL_OPERATOR_ADDRESS= -XRPL_ISSUER_ADDRESS= +# EVM Operator Account +EVM_OPERATOR_ADDRESS= # Admin API Key (generate a secure random string for production) ADMIN_API_KEY=dev-admin-key diff --git a/.foreman/requirements.md b/.foreman/requirements.md index d614fd8..c41ddc2 100644 --- a/.foreman/requirements.md +++ b/.foreman/requirements.md @@ -1,84 +1,16 @@ -# MITATE UI Migration Requirements - -## Background - -MITATE is an XRPL-powered prediction market for the JFIIP hackathon (demo day: Feb 24, 2026). The initial implementation is complete with: -- Backend API (Hono + SQLite + xrpl.js) -- XRPL integration (Escrow, Issued Currency, Trust Lines, DEX, Multi-Sign, Memo) -- Basic frontend (apps/web) with GemWallet integration - -A team member created a new UI design in `apps/mock/` with superior design and new features. This migration integrates that UI with our existing backend while adding new capabilities. - -## Goals - -1. Replace `apps/web` with the new UI from `apps/mock/` -2. Add multi-outcome market support (not just YES/NO) -3. Implement weighted betting system (user attributes affect bet weight) -4. Integrate with existing XRPL backend -5. Keep Japanese UI language -6. Use XRP as currency (not JPYC from mock) - -## Non-Goals - -- Admin panel UI (use API/CLI for market management) -- Mobile app -- Mainnet deployment (Testnet only for hackathon) -- JPYC or other stablecoins - -## Features to Migrate from apps/mock - -### UI Components -- Site header with wallet connection -- Market listing page with filter bar (category, status) -- Market detail page with outcomes list -- Bet panel with amount input and quick select -- My Page (portfolio with positions) -- Market card component with category badges - -### New Features to Implement -1. **Multi-Outcome Markets** — Support 2-5 outcomes per market (not just YES/NO) -2. **Weighted Betting** — User attributes (region, expertise, experience) multiply bet effectiveness -3. **Attribute System** — Users can register verified attributes that grant weight bonuses -4. **Category System** — Markets belong to categories (政治, 経済, 地域, etc.) - -## Technical Constraints - -- Keep bun as package manager -- Keep GemWallet as wallet (no other wallets) -- Keep XRP as currency (convert JPYC references) -- Keep existing API structure in apps/api -- Must work with existing XRPL transaction builders - -## API Changes Required - -### Markets -- Add `outcomes` field (array of {id, label, probability}) -- Add `category` and `categoryLabel` fields -- Change from hardcoded YES/NO to dynamic outcomes - -### Users/Attributes -- New endpoint: GET/POST /users/:address/attributes -- Store verified attributes per wallet address -- Calculate weight score from attributes - -### Bets -- Update to reference outcome ID (not just "YES"/"NO") -- Store weight multiplier applied at bet time -- Calculate effective amount (amount × weight) - -## Acceptance Criteria - -1. Homepage shows market list with category filter -2. Market detail shows all outcomes with probabilities -3. Users can connect GemWallet and see their address -4. Users can place bets on any outcome with XRP -5. Bet amounts are multiplied by user's weight score -6. Portfolio page shows user's positions -7. All XRPL transactions work (escrow, tokens, etc.) - -## Tech Stack - -- Frontend: Next.js 14+ (App Router), Tailwind CSS, shadcn/ui -- Backend: Hono, SQLite, xrpl.js -- Wallet: GemWallet (@gemwallet/api) -- XRPL: Testnet +# Migrate mitate from XRPL to EVM (no backward compatibility) + +## Goal +Replace all XRPL logic with EVM logic across the project. + +## Hard requirements +1. Branch `evm` must be used. +2. Remove all XRPL/XRLP logic, dependencies, adapters, configs, env vars, docs, and tests. +3. No backward compatibility layer. +4. Replace with EVM implementations only. +5. Keep the project buildable and tests passing. + +## Deliverables +- Fully migrated codebase on EVM +- Updated docs and configuration for EVM +- Clean test/build pass diff --git a/DEMO.md b/DEMO.md index 1b2bc65..17b1341 100644 --- a/DEMO.md +++ b/DEMO.md @@ -1,8 +1,8 @@ # MITATE Demo Guide -**Hackathon:** JFIIP Demo Day — February 24, 2026 -**Duration:** 3 minutes -**Audience:** Judges evaluating XRPL feature usage +**Hackathon:** JFIIP Demo Day — February 24, 2026 +**Duration:** 3 minutes +**Audience:** Judges evaluating EVM feature usage --- @@ -29,13 +29,10 @@ Create `apps/api/.env`: PORT=3001 NODE_ENV=development DATABASE_PATH=/data/mitate.db -XRPL_RPC_URL=https://s.altnet.rippletest.net:51234 -XRPL_WS_URL=wss://s.altnet.rippletest.net:51233 -XRPL_NETWORK_ID=1 +EVM_RPC_URL=https://sepolia.infura.io/v3/YOUR_KEY -# Get from testnet faucet: https://faucet.altnet.rippletest.net/accounts -XRPL_OPERATOR_ADDRESS=rYourOperatorAddress... -XRPL_ISSUER_ADDRESS=rYourIssuerAddress... +# Operator wallet address (0x...) +EVM_OPERATOR_ADDRESS=0xYourOperatorAddress... ADMIN_API_KEY=your-secure-admin-key ``` @@ -52,9 +49,10 @@ curl http://localhost:3001/health ### 3. Fund Wallets -Get testnet XRP for operator wallet: +Get testnet ETH for operator wallet from an EVM testnet faucet: ```bash -curl -X POST https://faucet.altnet.rippletest.net/accounts +# Example: Sepolia faucet +# https://sepoliafaucet.com ``` --- @@ -81,9 +79,9 @@ Click "Test Open" button next to the Draft market. ### Step 3: Place Bets (User Flow) 1. Go to `http://localhost:3000` -2. Connect GemWallet (must be on Testnet) +2. Connect MetaMask (must be on Testnet) 3. Click a market → Select outcome → Enter amount -4. Click "予測する" → Sign in GemWallet +4. Click "予測する" → Sign in MetaMask ### Step 4: Close Market (Admin UI) @@ -112,9 +110,9 @@ Response includes payout transactions to sign: "payouts": [ { "id": "pay_xxx", - "userId": "rWinnerAddress...", - "amountDrops": "15000000", - "payoutTx": { /* Payment tx to sign */ } + "userId": "0xWinnerAddress...", + "amountWei": "15000000000000000", + "payoutTx": { /* ETH transfer tx to sign */ } } ] } @@ -176,7 +174,7 @@ curl http://localhost:3001/api/markets/YOUR_MARKET_ID/payouts |----------|--------|-------------| | `/api/users/:address/bets` | GET | List user's bets | | `/api/users/:address/attributes` | GET | Get user attributes | -| `/balance/:address` | GET | Get XRP balance | +| `/balance/:address` | GET | Get ETH balance | --- @@ -184,7 +182,7 @@ curl http://localhost:3001/api/markets/YOUR_MARKET_ID/payouts ### Opening (0:00 - 0:20) -> "MITATE is a prediction market powered entirely by XRPL. It uses 6 native XRPL features — no smart contracts, pure XRPL." +> "MITATE is a prediction market powered by EVM. It uses native EVM features — ETH transfers, calldata, and multi-sign governance." **Show:** Homepage with markets @@ -195,26 +193,23 @@ curl http://localhost:3001/api/markets/YOUR_MARKET_ID/payouts > "Let's bet on the Miyagi governor election." **Actions:** -1. Connect GemWallet -2. Select market → Select outcome → Enter 5 XRP +1. Connect MetaMask +2. Select market → Select outcome → Enter 0.01 ETH 3. Sign transaction -> "My bet is recorded on XRPL with a memo containing the market and outcome." +> "My bet is recorded on-chain with calldata containing the market and outcome." --- -### Show XRPL Features (1:00 - 2:00) +### Show EVM Features (1:00 - 2:00) -> "Six XRPL features power this:" +> "Three EVM features power this:" -1. **Escrow** — "Bets locked with time-based deadline" -2. **Issued Currency** — "Each outcome has its own token" -3. **Trust Lines** — "Users opt-in to hold outcome tokens" -4. **DEX** — "Trade positions before resolution" -5. **Multi-Sign** — "Resolution requires committee approval" -6. **Memo** — "All transactions carry structured data" +1. **ETH Transfer** — "Bets sent directly to operator with deadline enforced by block_number" +2. **calldata** — "All transactions carry structured market and outcome data" +3. **Multi-Sign** — "Resolution requires committee approval" -**Show:** Transaction on XRPL Explorer +**Show:** Transaction on EVM Explorer --- @@ -228,32 +223,29 @@ curl http://localhost:3001/api/markets/YOUR_MARKET_ID/payouts ### Closing (2:50 - 3:00) -> "MITATE proves XRPL's native primitives can power a complete prediction market — all verifiable on-chain." +> "MITATE proves EVM's native primitives can power a complete prediction market — all verifiable on-chain." --- -## XRPL Features Summary +## EVM Features Summary | Feature | Usage | |---------|-------| -| **Escrow** | Time-locked XRP pool for bets | -| **Issued Currency** | Outcome tokens per market | -| **Trust Line** | Required to hold outcome tokens | -| **DEX** | Secondary trading of positions | +| **ETH Transfer** | Native ETH pool for bets and payouts | +| **calldata** | Structured audit trail on all transactions | | **Multi-Sign** | Resolution governance | -| **Memo** | Structured audit trail on all transactions | --- ## Troubleshooting -### "temREDUNDANT" Error -- Payment going to self (operator == bettor) -- Fix: Ensure `XRPL_OPERATOR_ADDRESS` is set and different from user +### "Nonce too low" Error +- Transaction sent with stale nonce +- Fix: Refresh MetaMask account or reset account nonce in MetaMask settings -### "LastLedgerSequence" Error -- Transaction expired before signing -- Fix: Try again quickly, or check network latency +### "Transaction underpriced" Error +- Gas price too low +- Fix: Increase gas price or use EIP-1559 fee estimation ### Market Stuck in Draft - Run `POST /api/markets/:id/test-open` to open @@ -274,5 +266,5 @@ curl http://localhost:3001/api/markets/YOUR_MARKET_ID/payouts | `apps/api/.env` | API configuration | | `apps/api/src/services/bets.ts` | Bet placement logic | | `apps/api/src/services/payouts.ts` | Payout calculation | -| `apps/api/src/xrpl/tx-builder.ts` | XRPL transaction builders | +| `apps/api/src/evm/tx-builder.ts` | EVM transaction builders | | `apps/web/app/admin/page.tsx` | Admin dashboard | diff --git a/README.md b/README.md index 8d54ffc..fc518fd 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,18 @@ # MITATE 見立て -> XRPL Parimutuel Prediction Market +> EVM Parimutuel Prediction Market **Demo Day: February 24, 2026** | [JFIIP Hackathon](https://jfiip.xrpl.org) ## Overview -MITATE is a prediction market DApp built on XRPL (XRP Ledger) using parimutuel betting mechanics. Users bet on binary outcomes (YES/NO) with XRP, and winners share the entire pool proportionally. +MITATE is a prediction market DApp built on EVM using parimutuel betting mechanics. Users bet on binary outcomes (YES/NO) with ETH, and winners share the entire pool proportionally. ### What Makes MITATE Special? -- **XRPL-Native Design**: Uses 6 XRPL primitives (Escrow, Issued Currency, Trust Line, DEX, Multi-Sign, Memo) +- **EVM-Native Design**: Uses EVM primitives (ETH transfer, calldata, Multi-Sign) - **Parimutuel Pricing**: No complex AMM math — simple pool-based payouts -- **Verifiable On-Chain**: All bets and outcomes recorded on XRPL ledger +- **Verifiable On-Chain**: All bets and outcomes recorded on the EVM blockchain - **Multi-Sign Resolution**: 2-of-3 governance prevents manipulation ## Architecture @@ -31,29 +31,25 @@ MITATE is a prediction market DApp built on XRPL (XRP Ledger) using parimutuel b │ ▼ ┌─────────────────────────────────────────────────────────────────┐ -│ XRPL Testnet │ -│ Escrow │ Issued Currency │ Trust Line │ DEX │ Multi-Sign │ +│ EVM Testnet │ +│ ETH Transfer │ calldata │ Multi-Sign │ └─────────────────────────────────────────────────────────────────┘ ``` -## XRPL Features Used +## EVM Features Used | Feature | Usage | |---------|-------| -| **Escrow** | Pool XRP bets with time-locked release | -| **Issued Currency** | YES/NO outcome tokens per market | -| **Trust Line** | Users hold outcome tokens | -| **DEX** | Secondary trading of outcome tokens | +| **ETH Transfer** | Pool ETH bets and release to winners | +| **calldata** | On-chain metadata for all transactions | | **Multi-Sign** | 2-of-3 resolution governance | -| **Memo** | On-chain metadata for all transactions | ## User Flow 1. **Market Created** → Admin creates market with betting deadline -2. **Bets Placed** → Users bet XRP on YES or NO, receive outcome tokens -3. **Trading** → Users can trade tokens on XRPL DEX before deadline -4. **Resolution** → Multi-sign committee resolves outcome -5. **Payout** → Winners receive proportional share of pool +2. **Bets Placed** → Users bet ETH on YES or NO +3. **Resolution** → Multi-sign committee resolves outcome +4. **Payout** → Winners receive proportional share of pool ## Tech Stack @@ -61,7 +57,7 @@ MITATE is a prediction market DApp built on XRPL (XRP Ledger) using parimutuel b |-------|------------| | Frontend | Next.js 16, React, Tailwind CSS, shadcn/ui | | Backend | Hono, Bun, SQLite (WAL mode) | -| Blockchain | XRPL Testnet, xrpl.js | +| Blockchain | EVM (Anvil locally / any EVM testnet), viem | | Deployment | Vercel (frontend), Fly.io (backend) | ## Development @@ -70,96 +66,196 @@ MITATE is a prediction market DApp built on XRPL (XRP Ledger) using parimutuel b - [Bun](https://bun.sh) 1.0+ - Node.js 20+ (for Next.js) -- XRPL Testnet accounts (operator, issuer) +- [Foundry](https://book.getfoundry.sh) (for Anvil local node) -### Option 1: Docker Compose (Recommended) +### Running Locally with Anvil (Recommended) + +[Anvil](https://book.getfoundry.sh/anvil/) is a local EVM node that ships with Foundry. It starts with 10 pre-funded accounts — no faucet or real ETH needed. + +#### 1. Install Foundry + +```bash +curl -L https://foundry.paradigm.xyz | bash +foundryup +``` + +Verify: + +```bash +anvil --version +``` + +#### 2. Clone and Install + +```bash +git clone https://github.com/thinkshake/mitate.git +cd mitate +bun install +``` + +#### 3. Start Anvil + +In a dedicated terminal: + +```bash +anvil +``` + +Anvil starts at `http://127.0.0.1:8545` (Chain ID: 31337) and prints pre-funded accounts: + +``` +Available Accounts +================== +(0) 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 (10000 ETH) + +Private Keys +================== +(0) 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 +``` + +Keep this terminal running throughout development. + +#### 4. Configure Environment + +**Backend:** + +```bash +cp apps/api/.env.example apps/api/.env +``` + +Edit `apps/api/.env`: + +```env +PORT=3001 +DATABASE_PATH=./data/mitate.db +EVM_RPC_URL=http://127.0.0.1:8545 +EVM_CHAIN_ID=31337 +EVM_OPERATOR_ADDRESS=0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 +EVM_OPERATOR_PRIVATE_KEY=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 +ADMIN_API_KEY=dev-admin-key +``` + +> ⚠️ These are Anvil's well-known dev keys — safe to use locally, **never use in production**. + +**Frontend:** + +```bash +echo 'NEXT_PUBLIC_API_URL=http://localhost:3001/api' > apps/web/.env.local +``` + +#### 5. Run Database Migrations + +```bash +cd apps/api +bun run migrate +cd ../.. +``` + +#### 6. Start the Services + +Open two more terminals: + +**Terminal 2 — API:** +```bash +cd apps/api +bun run dev +``` + +**Terminal 3 — Web:** +```bash +cd apps/web +bun run dev +``` + +#### 7. Verify Everything is Running + +| Service | URL | +|---------|-----| +| Frontend | http://localhost:3000 | +| Backend API | http://localhost:3001 | +| Anvil RPC | http://127.0.0.1:8545 | + +Quick sanity checks: + +```bash +# API health +curl http://localhost:3001/api/markets + +# Anvil block number +cast block-number --rpc-url http://127.0.0.1:8545 +``` + +--- + +### Option 2: Docker Compose (includes Anvil) + +Anvil is included as a service in `docker-compose.yml` — no separate install needed. ```bash -# Clone git clone https://github.com/thinkshake/mitate.git cd mitate -# Configure environment -cp .env.example .env -# Edit .env with XRPL addresses +# Configure the API environment (Anvil keys pre-filled) +cp apps/api/.env.example apps/api/.env +# EVM_RPC_URL and EVM_CHAIN_ID are overridden by docker-compose automatically -# Start all services +# Start everything (Anvil → API → Web) docker-compose up -d # View logs docker-compose logs -f - -# Stop services -docker-compose down ``` -- Frontend: http://localhost:3000 -- Backend API: http://localhost:3001 +| Service | URL | +|---------|-----| +| Frontend | http://localhost:3000 | +| Backend API | http://localhost:3001 | +| Anvil RPC | http://localhost:8545 | + +> The API container connects to Anvil via the internal Docker network (`http://anvil:8545`). +> Anvil is exposed on your host at `http://localhost:8545` for tools like `cast` or MetaMask. -### Option 2: Local Development +### Option 3: Manual Local (EVM Testnet) ```bash -# Clone git clone https://github.com/thinkshake/mitate.git cd mitate - -# Install dependencies bun install -# Configure environment cp apps/api/.env.example apps/api/.env -# Edit .env with XRPL addresses and API key +# Edit .env with your testnet RPC URL and operator wallet -# Run database migrations -cd apps/api && bun run migrate - -# Start development servers -bun run dev # Starts both frontend and backend +cd apps/api && bun run migrate && cd ../.. +bun run dev ``` -### Getting XRPL Testnet Accounts +### Getting Testnet Accounts (Option 3) -You need two XRPL Testnet accounts: **Operator** (holds escrow, receives bets) and **Issuer** (mints tokens). +You need an EVM testnet account as **Operator** (holds ETH, receives bets, sends payouts). -1. **Get accounts from the XRPL Testnet Faucet:** - ```bash - # Get Operator account - curl -X POST https://faucet.altnet.rippletest.net/accounts - # Response: {"account":{"xAddress":"...", "address":"rXXX...", "secret":"sXXX..."}, ...} - - # Get Issuer account (run again) - curl -X POST https://faucet.altnet.rippletest.net/accounts - ``` - - Or use the web interface: https://xrpl.org/resources/dev-tools/xrp-faucets - -2. **Save the addresses:** - - `XRPL_OPERATOR_ADDRESS` = first account's `address` (starts with `r`) - - `XRPL_ISSUER_ADDRESS` = second account's `address` (starts with `r`) - - Keep the `secret` values safe — needed for signing transactions +1. Get testnet ETH (e.g. Sepolia): https://sepoliafaucet.com + Or generate a wallet: `cast wallet new` -3. **Generate Admin API Key:** +2. Generate an Admin API Key: ```bash - # Generate a random 32-character key openssl rand -hex 16 - # Or use any secure random string generator ``` -### Environment Variables +### Environment Variables Reference **Backend (apps/api/.env)** -``` +```env PORT=3001 DATABASE_PATH=./data/mitate.db -XRPL_RPC_URL=https://s.altnet.rippletest.net:51234 -XRPL_WS_URL=wss://s.altnet.rippletest.net:51233 -XRPL_OPERATOR_ADDRESS=rXXX... # From faucet step 1 -XRPL_ISSUER_ADDRESS=rYYY... # From faucet step 1 -ADMIN_API_KEY=your-secret-key # From step 3 +EVM_RPC_URL=http://127.0.0.1:8545 # Anvil; or https://sepolia.infura.io/v3/KEY +EVM_CHAIN_ID=31337 # Anvil; or 11155111 for Sepolia +EVM_OPERATOR_ADDRESS=0xYourOperatorAddress +EVM_OPERATOR_PRIVATE_KEY=0xYourPrivateKey +ADMIN_API_KEY=your-secret-key ``` **Frontend (apps/web/.env.local)** -``` +```env NEXT_PUBLIC_API_URL=http://localhost:3001/api ``` @@ -177,7 +273,7 @@ NEXT_PUBLIC_API_URL=http://localhost:3001/api - `GET /api/markets/:id/bets/preview` — Preview payout ### Trading -- `POST /api/markets/:id/offers` — Create DEX offer +- `POST /api/markets/:id/offers` — Create EVM trade - `GET /api/markets/:id/trades` — List trades ### Resolution @@ -192,8 +288,8 @@ NEXT_PUBLIC_API_URL=http://localhost:3001/api ```bash cd apps/api fly launch -fly secrets set XRPL_OPERATOR_ADDRESS=rXXX... -fly secrets set XRPL_ISSUER_ADDRESS=rYYY... +fly secrets set EVM_OPERATOR_ADDRESS=0xXXX... +fly secrets set EVM_OPERATOR_PRIVATE_KEY=0xXXX... fly secrets set ADMIN_API_KEY=your-secret-key fly deploy ``` diff --git a/apps/api/.env.example b/apps/api/.env.example index 761df3c..9c76ed7 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -5,18 +5,14 @@ NODE_ENV=development # Database DATABASE_PATH=./data/mitate.db -# XRPL Testnet -XRPL_RPC_URL=https://s.altnet.rippletest.net:51234 -XRPL_WS_URL=wss://s.altnet.rippletest.net:51233 -XRPL_NETWORK_ID=1 +# EVM RPC endpoint (any EVM-compatible chain) +EVM_RPC_URL=http://localhost:8545 +EVM_CHAIN_ID=1337 -# XRPL Accounts (get from testnet faucet: https://xrpl.org/xrp-testnet-faucet.html) -XRPL_OPERATOR_ADDRESS= -XRPL_ISSUER_ADDRESS= -# Issuer secret for auto-minting tokens (testnet only - never commit real secrets!) -XRPL_ISSUER_SECRET= -# Operator secret for auto-payouts (testnet only - never commit real secrets!) -XRPL_OPERATOR_SECRET= +# EVM Accounts +EVM_OPERATOR_ADDRESS= +# Operator private key for auto-payouts (never commit real secrets!) +EVM_OPERATOR_PRIVATE_KEY= # Admin API Key (generate a secure random string for production) ADMIN_API_KEY=dev-admin-key diff --git a/apps/api/package.json b/apps/api/package.json index ebd8063..7d1a346 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -13,7 +13,7 @@ "dependencies": { "@hono/zod-validator": "^0.7.6", "hono": "^4.7.0", - "xrpl": "^4.2.0", + "viem": "^2.21.0", "zod": "^4.3.6" }, "devDependencies": { diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts index 05c56ed..f9d8d1e 100644 --- a/apps/api/src/config.ts +++ b/apps/api/src/config.ts @@ -11,26 +11,17 @@ export const config = { /** SQLite database path */ databasePath: process.env.DATABASE_PATH || "./data/mitate.db", - /** XRPL Testnet JSON-RPC endpoint */ - xrplRpcUrl: process.env.XRPL_RPC_URL || "https://s.altnet.rippletest.net:51234", + /** EVM JSON-RPC endpoint */ + evmRpcUrl: process.env.EVM_RPC_URL || "http://localhost:8545", - /** XRPL Testnet WebSocket endpoint */ - xrplWsUrl: process.env.XRPL_WS_URL || "wss://s.altnet.rippletest.net:51233", + /** EVM Chain ID */ + evmChainId: parseInt(process.env.EVM_CHAIN_ID || "1337", 10), - /** XRPL Network ID (for validation) */ - xrplNetworkId: parseInt(process.env.XRPL_NETWORK_ID || "1", 10), // 1 = Testnet + /** Market operator EVM address (receives bet payments) */ + operatorAddress: process.env.EVM_OPERATOR_ADDRESS || "", - /** Market operator address */ - operatorAddress: process.env.XRPL_OPERATOR_ADDRESS || "", - - /** Market issuer address */ - issuerAddress: process.env.XRPL_ISSUER_ADDRESS || "", - - /** Market issuer secret (for auto-minting tokens) */ - issuerSecret: process.env.XRPL_ISSUER_SECRET || "", - - /** Market operator secret (for auto-payouts) */ - operatorSecret: process.env.XRPL_OPERATOR_SECRET || "", + /** Market operator private key (for auto-payouts) */ + operatorPrivateKey: process.env.EVM_OPERATOR_PRIVATE_KEY || "", /** Admin API key for privileged operations */ adminApiKey: process.env.ADMIN_API_KEY || "", @@ -44,10 +35,7 @@ export function validateConfig(): string[] { if (config.nodeEnv === "production") { if (!config.operatorAddress) { - errors.push("XRPL_OPERATOR_ADDRESS is required in production"); - } - if (!config.issuerAddress) { - errors.push("XRPL_ISSUER_ADDRESS is required in production"); + errors.push("EVM_OPERATOR_ADDRESS is required in production"); } if (!config.adminApiKey) { errors.push("ADMIN_API_KEY is required in production"); diff --git a/apps/api/src/db/migrations/001_initial_schema.sql b/apps/api/src/db/migrations/001_initial_schema.sql index 1911ddf..e341deb 100644 --- a/apps/api/src/db/migrations/001_initial_schema.sql +++ b/apps/api/src/db/migrations/001_initial_schema.sql @@ -1,11 +1,10 @@ --- MITATE Initial Schema --- Based on data-model.md +-- MITATE Initial Schema (EVM) -- Users table CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY, wallet_address TEXT NOT NULL UNIQUE, - provider TEXT NOT NULL, -- xaman|gemwallet|manual + provider TEXT NOT NULL, -- metamask|walletconnect|manual created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) ); @@ -23,16 +22,10 @@ CREATE TABLE IF NOT EXISTS markets ( resolution_time TEXT, created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), - xrpl_market_tx TEXT, -- tx hash for market creation - xrpl_escrow_sequence INTEGER, - xrpl_escrow_tx TEXT, - xrpl_escrow_finish_tx TEXT, - xrpl_escrow_cancel_tx TEXT, - pool_total_drops TEXT NOT NULL DEFAULT '0', - yes_total_drops TEXT NOT NULL DEFAULT '0', - no_total_drops TEXT NOT NULL DEFAULT '0', - issuer_address TEXT NOT NULL, - operator_address TEXT NOT NULL + pool_total_wei TEXT NOT NULL DEFAULT '0', + yes_total_wei TEXT NOT NULL DEFAULT '0', + no_total_wei TEXT NOT NULL DEFAULT '0', + operator_address TEXT NOT NULL DEFAULT '' ); -- Bets table @@ -41,34 +34,15 @@ CREATE TABLE IF NOT EXISTS bets ( market_id TEXT NOT NULL, user_id TEXT NOT NULL, outcome TEXT NOT NULL, -- YES|NO - amount_drops TEXT NOT NULL, + amount_wei TEXT NOT NULL, status TEXT NOT NULL, -- Pending|Confirmed|Failed|Refunded placed_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), - payment_tx TEXT, -- user Payment tx hash - escrow_tx TEXT, -- escrow creation tx hash - mint_tx TEXT, -- issuer Payment tx hash + payment_tx TEXT, -- user tx hash memo_json TEXT, FOREIGN KEY (market_id) REFERENCES markets(id), FOREIGN KEY (user_id) REFERENCES users(id) ); --- Escrows table -CREATE TABLE IF NOT EXISTS escrows ( - id TEXT PRIMARY KEY, - market_id TEXT NOT NULL, - amount_drops TEXT NOT NULL, - status TEXT NOT NULL, -- Open|Finished|Canceled - sequence INTEGER NOT NULL, - create_tx TEXT NOT NULL, - finish_tx TEXT, - cancel_tx TEXT, - cancel_after INTEGER NOT NULL, - finish_after INTEGER, - created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), - updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), - FOREIGN KEY (market_id) REFERENCES markets(id) -); - -- Trades table CREATE TABLE IF NOT EXISTS trades ( id TEXT PRIMARY KEY, @@ -77,7 +51,7 @@ CREATE TABLE IF NOT EXISTS trades ( taker_gets TEXT NOT NULL, taker_pays TEXT NOT NULL, executed_at TEXT NOT NULL, - ledger_index INTEGER NOT NULL, + block_number INTEGER NOT NULL, memo_json TEXT, FOREIGN KEY (market_id) REFERENCES markets(id) ); @@ -87,7 +61,7 @@ CREATE TABLE IF NOT EXISTS payouts ( id TEXT PRIMARY KEY, market_id TEXT NOT NULL, user_id TEXT NOT NULL, - amount_drops TEXT NOT NULL, + amount_wei TEXT NOT NULL, status TEXT NOT NULL, -- Pending|Sent|Failed payout_tx TEXT, created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), @@ -107,33 +81,32 @@ CREATE TABLE IF NOT EXISTS wallet_links ( FOREIGN KEY (user_id) REFERENCES users(id) ); --- Ledger events table (for idempotent XRPL event ingestion) -CREATE TABLE IF NOT EXISTS ledger_events ( +-- Chain events table (for idempotent EVM event ingestion) +CREATE TABLE IF NOT EXISTS chain_events ( id TEXT PRIMARY KEY, tx_hash TEXT NOT NULL UNIQUE, event_type TEXT NOT NULL, market_id TEXT, payload_json TEXT NOT NULL, - ledger_index INTEGER NOT NULL, + block_number INTEGER NOT NULL, created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) ); --- System state table (for tracking sync state, etc.) +-- System state table CREATE TABLE IF NOT EXISTS system_state ( key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) ); --- Indexes for query performance +-- Indexes CREATE INDEX IF NOT EXISTS idx_markets_status ON markets(status); CREATE INDEX IF NOT EXISTS idx_markets_deadline ON markets(betting_deadline); CREATE INDEX IF NOT EXISTS idx_bets_market ON bets(market_id); CREATE INDEX IF NOT EXISTS idx_bets_user ON bets(user_id); CREATE INDEX IF NOT EXISTS idx_bets_status ON bets(status); -CREATE INDEX IF NOT EXISTS idx_escrows_market ON escrows(market_id); CREATE INDEX IF NOT EXISTS idx_trades_market ON trades(market_id); CREATE INDEX IF NOT EXISTS idx_payouts_market ON payouts(market_id); CREATE INDEX IF NOT EXISTS idx_payouts_user ON payouts(user_id); -CREATE INDEX IF NOT EXISTS idx_ledger_events_market ON ledger_events(market_id); -CREATE INDEX IF NOT EXISTS idx_ledger_events_ledger ON ledger_events(ledger_index); +CREATE INDEX IF NOT EXISTS idx_chain_events_market ON chain_events(market_id); +CREATE INDEX IF NOT EXISTS idx_chain_events_block ON chain_events(block_number); diff --git a/apps/api/src/db/migrations/002_multi_outcome.sql b/apps/api/src/db/migrations/002_multi_outcome.sql index 2974969..fad9e4e 100644 --- a/apps/api/src/db/migrations/002_multi_outcome.sql +++ b/apps/api/src/db/migrations/002_multi_outcome.sql @@ -1,4 +1,4 @@ --- MITATE Multi-Outcome Migration +-- MITATE Multi-Outcome Migration (EVM) -- Adds multi-outcome markets, user attributes, and weighted betting -- ── Markets: add category_label and resolved_outcome_id ──────────── @@ -12,8 +12,7 @@ CREATE TABLE IF NOT EXISTS outcomes ( id TEXT PRIMARY KEY, market_id TEXT NOT NULL, label TEXT NOT NULL, - currency_code TEXT, - total_amount_drops TEXT NOT NULL DEFAULT '0', + total_amount_wei TEXT NOT NULL DEFAULT '0', display_order INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), FOREIGN KEY (market_id) REFERENCES markets(id) @@ -36,13 +35,13 @@ CREATE TABLE IF NOT EXISTS user_attributes ( CREATE INDEX IF NOT EXISTS idx_user_attributes_wallet ON user_attributes(wallet_address); --- ── Bets: add outcome_id, weight_score, effective_amount_drops ──── +-- ── Bets: add outcome_id, weight_score, effective_amount_wei ────── ALTER TABLE bets ADD COLUMN outcome_id TEXT REFERENCES outcomes(id); ALTER TABLE bets ADD COLUMN weight_score REAL NOT NULL DEFAULT 1.0; -ALTER TABLE bets ADD COLUMN effective_amount_drops TEXT; +ALTER TABLE bets ADD COLUMN effective_amount_wei TEXT; CREATE INDEX IF NOT EXISTS idx_bets_outcome ON bets(outcome_id); --- ── Backfill: set effective_amount_drops = amount_drops for existing bets -UPDATE bets SET effective_amount_drops = amount_drops WHERE effective_amount_drops IS NULL; +-- ── Backfill: set effective_amount_wei = amount_wei for existing bets +UPDATE bets SET effective_amount_wei = amount_wei WHERE effective_amount_wei IS NULL; diff --git a/apps/api/src/db/models/bets.ts b/apps/api/src/db/models/bets.ts index e064a4b..ef51bf1 100644 --- a/apps/api/src/db/models/bets.ts +++ b/apps/api/src/db/models/bets.ts @@ -14,14 +14,12 @@ export interface Bet { user_id: string; outcome: BetOutcome; outcome_id: string | null; - amount_drops: string; + amount_wei: string; weight_score: number; - effective_amount_drops: string | null; + effective_amount_wei: string | null; status: BetStatus; placed_at: string; payment_tx: string | null; - escrow_tx: string | null; - mint_tx: string | null; memo_json: string | null; } @@ -30,35 +28,30 @@ export interface BetInsert { userId: string; outcome: BetOutcome; outcomeId?: string; - amountDrops: string; + amountWei: string; weightScore?: number; - effectiveAmountDrops?: string; + effectiveAmountWei?: string; memoJson?: string; } export interface BetUpdate { status?: BetStatus; paymentTx?: string; - escrowTx?: string; - mintTx?: string; } // ── Queries ──────────────────────────────────────────────────────── -/** - * Create a new bet (initially Pending). - */ export function createBet(bet: BetInsert): Bet { const db = getDb(); const id = generateId("bet"); const weightScore = bet.weightScore ?? 1.0; - const effectiveAmountDrops = - bet.effectiveAmountDrops ?? - Math.round(Number(bet.amountDrops) * weightScore).toString(); + const effectiveAmountWei = + bet.effectiveAmountWei ?? + Math.round(Number(bet.amountWei) * weightScore).toString(); db.query( - `INSERT INTO bets (id, market_id, user_id, outcome, outcome_id, amount_drops, - weight_score, effective_amount_drops, status, memo_json) + `INSERT INTO bets (id, market_id, user_id, outcome, outcome_id, amount_wei, + weight_score, effective_amount_wei, status, memo_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'Pending', ?)` ).run( id, @@ -66,34 +59,25 @@ export function createBet(bet: BetInsert): Bet { bet.userId, bet.outcome, bet.outcomeId ?? null, - bet.amountDrops, + bet.amountWei, weightScore, - effectiveAmountDrops, + effectiveAmountWei, bet.memoJson ?? null ); return getBetById(id)!; } -/** - * Get a bet by ID. - */ export function getBetById(id: string): Bet | null { const db = getDb(); return db.query("SELECT * FROM bets WHERE id = ?").get(id) as Bet | null; } -/** - * Get a bet by payment transaction hash. - */ export function getBetByPaymentTx(paymentTx: string): Bet | null { const db = getDb(); return db.query("SELECT * FROM bets WHERE payment_tx = ?").get(paymentTx) as Bet | null; } -/** - * List bets for a market. - */ export function listBetsByMarket(marketId: string, status?: BetStatus): Bet[] { const db = getDb(); if (status) { @@ -106,9 +90,6 @@ export function listBetsByMarket(marketId: string, status?: BetStatus): Bet[] { ).all(marketId) as Bet[]; } -/** - * List bets for a user. - */ export function listBetsByUser(userId: string): Bet[] { const db = getDb(); return db.query( @@ -116,28 +97,16 @@ export function listBetsByUser(userId: string): Bet[] { ).all(userId) as Bet[]; } -/** - * List confirmed bets for a market and outcome. - */ -export function listConfirmedBetsByOutcome( - marketId: string, - outcome: BetOutcome -): Bet[] { +export function listConfirmedBetsByOutcome(marketId: string, outcome: BetOutcome): Bet[] { const db = getDb(); return db.query( - `SELECT * FROM bets + `SELECT * FROM bets WHERE market_id = ? AND outcome = ? AND status = 'Confirmed' ORDER BY placed_at ASC` ).all(marketId, outcome) as Bet[]; } -/** - * List confirmed bets for a multi-outcome market by outcome_id. - */ -export function listConfirmedBetsByOutcomeId( - marketId: string, - outcomeId: string -): Bet[] { +export function listConfirmedBetsByOutcomeId(marketId: string, outcomeId: string): Bet[] { const db = getDb(); return db.query( `SELECT * FROM bets @@ -146,101 +115,58 @@ export function listConfirmedBetsByOutcomeId( ).all(marketId, outcomeId) as Bet[]; } -/** - * Get total effective amount for a market's outcome_id. - */ -export function getTotalEffectiveAmount( - marketId: string, - outcomeId?: string -): string { +export function getTotalEffectiveAmount(marketId: string, outcomeId?: string): string { const db = getDb(); if (outcomeId) { const result = db.query( - `SELECT COALESCE(SUM(CAST(effective_amount_drops AS INTEGER)), 0) as total + `SELECT COALESCE(SUM(CAST(effective_amount_wei AS INTEGER)), 0) as total FROM bets WHERE market_id = ? AND outcome_id = ? AND status = 'Confirmed'` ).get(marketId, outcomeId) as { total: number }; return result.total.toString(); } const result = db.query( - `SELECT COALESCE(SUM(CAST(effective_amount_drops AS INTEGER)), 0) as total + `SELECT COALESCE(SUM(CAST(effective_amount_wei AS INTEGER)), 0) as total FROM bets WHERE market_id = ? AND status = 'Confirmed'` ).get(marketId) as { total: number }; return result.total.toString(); } -/** - * Update a bet. - */ export function updateBet(id: string, update: BetUpdate): Bet | null { const db = getDb(); const sets: string[] = []; const values: (string | number | null)[] = []; - if (update.status !== undefined) { - sets.push("status = ?"); - values.push(update.status); - } - if (update.paymentTx !== undefined) { - sets.push("payment_tx = ?"); - values.push(update.paymentTx); - } - if (update.escrowTx !== undefined) { - sets.push("escrow_tx = ?"); - values.push(update.escrowTx); - } - if (update.mintTx !== undefined) { - sets.push("mint_tx = ?"); - values.push(update.mintTx); - } + if (update.status !== undefined) { sets.push("status = ?"); values.push(update.status); } + if (update.paymentTx !== undefined) { sets.push("payment_tx = ?"); values.push(update.paymentTx); } - if (sets.length === 0) { - return getBetById(id); - } + if (sets.length === 0) return getBetById(id); values.push(id); db.query(`UPDATE bets SET ${sets.join(", ")} WHERE id = ?`).run(...values); return getBetById(id); } -/** - * Calculate total bet amount for a market and outcome. - */ export function getTotalBetAmount(marketId: string, outcome?: BetOutcome): string { const db = getDb(); if (outcome) { const result = db.query( - `SELECT COALESCE(SUM(CAST(amount_drops AS INTEGER)), 0) as total + `SELECT COALESCE(SUM(CAST(amount_wei AS INTEGER)), 0) as total FROM bets WHERE market_id = ? AND outcome = ? AND status = 'Confirmed'` ).get(marketId, outcome) as { total: number }; return result.total.toString(); } const result = db.query( - `SELECT COALESCE(SUM(CAST(amount_drops AS INTEGER)), 0) as total + `SELECT COALESCE(SUM(CAST(amount_wei AS INTEGER)), 0) as total FROM bets WHERE market_id = ? AND status = 'Confirmed'` ).get(marketId) as { total: number }; return result.total.toString(); } -/** - * Get pending bets older than a threshold (for cleanup). - */ export function getStalePendingBets(olderThanMinutes: number): Bet[] { const db = getDb(); return db.query( - `SELECT * FROM bets - WHERE status = 'Pending' + `SELECT * FROM bets + WHERE status = 'Pending' AND datetime(placed_at) < datetime('now', '-' || ? || ' minutes')` ).all(olderThanMinutes) as Bet[]; } - -/** - * Get confirmed bets that need token minting (no mint_tx yet). - */ -export function getPendingMints(): Bet[] { - const db = getDb(); - return db.query( - `SELECT * FROM bets - WHERE status = 'Confirmed' AND mint_tx IS NULL - ORDER BY placed_at ASC` - ).all() as Bet[]; -} diff --git a/apps/api/src/db/models/escrows.ts b/apps/api/src/db/models/escrows.ts deleted file mode 100644 index 919490f..0000000 --- a/apps/api/src/db/models/escrows.ts +++ /dev/null @@ -1,177 +0,0 @@ -/** - * DB model for the escrows table. - */ -import { getDb, generateId } from "../index"; - -// ── Types ────────────────────────────────────────────────────────── - -export type EscrowStatus = "Open" | "Finished" | "Canceled"; - -export interface Escrow { - id: string; - market_id: string; - amount_drops: string; - status: EscrowStatus; - sequence: number; - create_tx: string; - finish_tx: string | null; - cancel_tx: string | null; - cancel_after: number; - finish_after: number | null; - created_at: string; - updated_at: string; -} - -export interface EscrowInsert { - marketId: string; - amountDrops: string; - sequence: number; - createTx: string; - cancelAfter: number; - finishAfter?: number; -} - -export interface EscrowUpdate { - status?: EscrowStatus; - amountDrops?: string; - finishTx?: string; - cancelTx?: string; -} - -// ── Queries ──────────────────────────────────────────────────────── - -/** - * Create a new escrow record. - */ -export function createEscrow(escrow: EscrowInsert): Escrow { - const db = getDb(); - const id = generateId("esc"); - - db.query( - `INSERT INTO escrows ( - id, market_id, amount_drops, status, sequence, - create_tx, cancel_after, finish_after - ) VALUES (?, ?, ?, 'Open', ?, ?, ?, ?)` - ).run( - id, - escrow.marketId, - escrow.amountDrops, - escrow.sequence, - escrow.createTx, - escrow.cancelAfter, - escrow.finishAfter ?? null - ); - - return getEscrowById(id)!; -} - -/** - * Get an escrow by ID. - */ -export function getEscrowById(id: string): Escrow | null { - const db = getDb(); - return db.query("SELECT * FROM escrows WHERE id = ?").get(id) as Escrow | null; -} - -/** - * Get the primary escrow for a market. - */ -export function getEscrowByMarket(marketId: string): Escrow | null { - const db = getDb(); - return db.query( - "SELECT * FROM escrows WHERE market_id = ? AND status = 'Open' ORDER BY created_at DESC LIMIT 1" - ).get(marketId) as Escrow | null; -} - -/** - * Get an escrow by its XRPL sequence number. - */ -export function getEscrowBySequence(sequence: number): Escrow | null { - const db = getDb(); - return db.query( - "SELECT * FROM escrows WHERE sequence = ?" - ).get(sequence) as Escrow | null; -} - -/** - * List all escrows for a market. - */ -export function listEscrowsByMarket(marketId: string): Escrow[] { - const db = getDb(); - return db.query( - "SELECT * FROM escrows WHERE market_id = ? ORDER BY created_at DESC" - ).all(marketId) as Escrow[]; -} - -/** - * Update an escrow. - */ -export function updateEscrow(id: string, update: EscrowUpdate): Escrow | null { - const db = getDb(); - const sets: string[] = []; - const values: (string | number | null)[] = []; - - if (update.status !== undefined) { - sets.push("status = ?"); - values.push(update.status); - } - if (update.amountDrops !== undefined) { - sets.push("amount_drops = ?"); - values.push(update.amountDrops); - } - if (update.finishTx !== undefined) { - sets.push("finish_tx = ?"); - values.push(update.finishTx); - } - if (update.cancelTx !== undefined) { - sets.push("cancel_tx = ?"); - values.push(update.cancelTx); - } - - if (sets.length === 0) { - return getEscrowById(id); - } - - sets.push("updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')"); - values.push(id); - - db.query(`UPDATE escrows SET ${sets.join(", ")} WHERE id = ?`).run(...values); - return getEscrowById(id); -} - -/** - * Add amount to an existing escrow. - */ -export function addToEscrow(id: string, additionalDrops: string): void { - const db = getDb(); - db.query( - `UPDATE escrows SET - amount_drops = CAST(CAST(amount_drops AS INTEGER) + ? AS TEXT), - updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') - WHERE id = ?` - ).run(additionalDrops, id); -} - -/** - * Get escrows that can be canceled (past cancel_after time). - */ -export function getCancelableEscrows(): Escrow[] { - const db = getDb(); - const now = Math.floor(Date.now() / 1000); - return db.query( - `SELECT * FROM escrows - WHERE status = 'Open' AND cancel_after <= ?` - ).all(now) as Escrow[]; -} - -/** - * Get escrows that can be finished (past finish_after time). - */ -export function getFinishableEscrows(): Escrow[] { - const db = getDb(); - const now = Math.floor(Date.now() / 1000); - return db.query( - `SELECT * FROM escrows - WHERE status = 'Open' AND finish_after IS NOT NULL AND finish_after <= ?` - ).all(now) as Escrow[]; -} diff --git a/apps/api/src/db/models/ledger-events.ts b/apps/api/src/db/models/ledger-events.ts index d278878..55934fa 100644 --- a/apps/api/src/db/models/ledger-events.ts +++ b/apps/api/src/db/models/ledger-events.ts @@ -1,42 +1,39 @@ /** - * DB model for the ledger_events table. + * DB model for the chain_events table. * - * Provides idempotent ingestion of XRPL transaction events via + * Provides idempotent ingestion of EVM transaction events via * INSERT OR IGNORE on the unique tx_hash column. */ import { getDb, generateId } from "../index"; // ── Types ────────────────────────────────────────────────────────── -export interface LedgerEvent { +export interface ChainEvent { id: string; tx_hash: string; event_type: string; market_id: string | null; payload_json: string; - ledger_index: number; + block_number: number; created_at: string; } -export interface LedgerEventInsert { +export interface ChainEventInsert { txHash: string; eventType: string; marketId?: string; payloadJson: string; - ledgerIndex: number; + blockNumber: number; } // ── Queries ──────────────────────────────────────────────────────── -/** - * Insert a ledger event idempotently (duplicates silently ignored). - */ -export function insertLedgerEvent(event: LedgerEventInsert): void { +export function insertChainEvent(event: ChainEventInsert): void { const db = getDb(); const id = generateId("evt"); db.query( - `INSERT OR IGNORE INTO ledger_events - (id, tx_hash, event_type, market_id, payload_json, ledger_index) + `INSERT OR IGNORE INTO chain_events + (id, tx_hash, event_type, market_id, payload_json, block_number) VALUES (?, ?, ?, ?, ?, ?)` ).run( id, @@ -44,56 +41,24 @@ export function insertLedgerEvent(event: LedgerEventInsert): void { event.eventType, event.marketId ?? null, event.payloadJson, - event.ledgerIndex + event.blockNumber ); } -/** - * Lookup a single event by its XRPL transaction hash. - */ -export function getLedgerEventByTxHash( - txHash: string -): LedgerEvent | null { +export function getChainEventByTxHash(txHash: string): ChainEvent | null { const db = getDb(); return ( db - .query("SELECT * FROM ledger_events WHERE tx_hash = ?") - .get(txHash) as LedgerEvent | null + .query("SELECT * FROM chain_events WHERE tx_hash = ?") + .get(txHash) as ChainEvent | null ); } -/** - * List all events for a market, ascending by ledger index. - */ -export function getLedgerEventsByMarket( - marketId: string -): LedgerEvent[] { - const db = getDb(); - return db - .query( - "SELECT * FROM ledger_events WHERE market_id = ? ORDER BY ledger_index ASC" - ) - .all(marketId) as LedgerEvent[]; -} - -/** - * List events filtered by type (and optionally market). - */ -export function getLedgerEventsByType( - eventType: string, - marketId?: string -): LedgerEvent[] { +export function getChainEventsByMarket(marketId: string): ChainEvent[] { const db = getDb(); - if (marketId) { - return db - .query( - "SELECT * FROM ledger_events WHERE event_type = ? AND market_id = ? ORDER BY ledger_index ASC" - ) - .all(eventType, marketId) as LedgerEvent[]; - } return db .query( - "SELECT * FROM ledger_events WHERE event_type = ? ORDER BY ledger_index ASC" + "SELECT * FROM chain_events WHERE market_id = ? ORDER BY block_number ASC" ) - .all(eventType) as LedgerEvent[]; + .all(marketId) as ChainEvent[]; } diff --git a/apps/api/src/db/models/markets.ts b/apps/api/src/db/models/markets.ts index c2b5c0b..c81c9db 100644 --- a/apps/api/src/db/models/markets.ts +++ b/apps/api/src/db/models/markets.ts @@ -6,13 +6,13 @@ import { createOutcomesBatch, getOutcomesWithProbability, type Outcome } from ". // ── Types ────────────────────────────────────────────────────────── -export type MarketStatus = - | "Draft" - | "Open" - | "Closed" - | "Resolved" - | "Paid" - | "Canceled" +export type MarketStatus = + | "Draft" + | "Open" + | "Closed" + | "Resolved" + | "Paid" + | "Canceled" | "Stalled"; export type MarketOutcome = "YES" | "NO"; @@ -31,15 +31,9 @@ export interface Market { resolution_time: string | null; created_at: string; updated_at: string; - xrpl_market_tx: string | null; - xrpl_escrow_sequence: number | null; - xrpl_escrow_tx: string | null; - xrpl_escrow_finish_tx: string | null; - xrpl_escrow_cancel_tx: string | null; - pool_total_drops: string; - yes_total_drops: string; - no_total_drops: string; - issuer_address: string; + pool_total_wei: string; + yes_total_wei: string; + no_total_wei: string; operator_address: string; } @@ -55,7 +49,6 @@ export interface MarketInsert { createdBy: string; bettingDeadline: string; resolutionTime?: string; - issuerAddress: string; operatorAddress: string; } @@ -73,23 +66,14 @@ export interface MarketUpdate { resolvedOutcomeId?: string; bettingDeadline?: string; resolutionTime?: string; - xrplMarketTx?: string; - xrplEscrowSequence?: number; - xrplEscrowTx?: string; - xrplEscrowFinishTx?: string; - xrplEscrowCancelTx?: string; - poolTotalDrops?: string; - yesTotalDrops?: string; - noTotalDrops?: string; + poolTotalWei?: string; + yesTotalWei?: string; + noTotalWei?: string; operatorAddress?: string; - issuerAddress?: string; } // ── Queries ──────────────────────────────────────────────────────── -/** - * Create a new market. - */ export function createMarket(market: MarketInsert): Market { const db = getDb(); const id = generateId("mkt"); @@ -97,8 +81,8 @@ export function createMarket(market: MarketInsert): Market { db.query( `INSERT INTO markets ( id, title, description, category, category_label, status, created_by, - betting_deadline, resolution_time, issuer_address, operator_address - ) VALUES (?, ?, ?, ?, ?, 'Draft', ?, ?, ?, ?, ?)` + betting_deadline, resolution_time, operator_address + ) VALUES (?, ?, ?, ?, ?, 'Draft', ?, ?, ?, ?)` ).run( id, market.title, @@ -108,16 +92,12 @@ export function createMarket(market: MarketInsert): Market { market.createdBy, market.bettingDeadline, market.resolutionTime ?? null, - market.issuerAddress, market.operatorAddress ); return getMarketById(id)!; } -/** - * Create a market with outcomes in a single transaction. - */ export function createMarketWithOutcomes( market: MarketWithOutcomesInsert ): MarketWithOutcomes { @@ -137,20 +117,13 @@ export function createMarketWithOutcomes( } } -/** - * Get a market with its outcomes and calculated probabilities. - */ export function getMarketWithOutcomes(id: string): MarketWithOutcomes | null { const market = getMarketById(id); if (!market) return null; - const outcomes = getOutcomesWithProbability(id); return { ...market, outcomes }; } -/** - * List all markets with outcomes attached. - */ export function listMarketsWithOutcomes( filters?: { status?: MarketStatus; category?: string } ): MarketWithOutcomes[] { @@ -178,17 +151,11 @@ export function listMarketsWithOutcomes( })); } -/** - * Get a market by ID. - */ export function getMarketById(id: string): Market | null { const db = getDb(); return db.query("SELECT * FROM markets WHERE id = ?").get(id) as Market | null; } -/** - * List all markets with optional status filter. - */ export function listMarkets(status?: MarketStatus): Market[] { const db = getDb(); if (status) { @@ -198,107 +165,36 @@ export function listMarkets(status?: MarketStatus): Market[] { return db.query("SELECT * FROM markets ORDER BY created_at DESC").all() as Market[]; } -/** - * List open markets (for betting). - */ export function listOpenMarkets(): Market[] { const db = getDb(); return db.query( - `SELECT * FROM markets - WHERE status = 'Open' + `SELECT * FROM markets + WHERE status = 'Open' AND betting_deadline > strftime('%Y-%m-%dT%H:%M:%fZ','now') ORDER BY betting_deadline ASC` ).all() as Market[]; } -/** - * Update a market. - */ export function updateMarket(id: string, update: MarketUpdate): Market | null { const db = getDb(); const sets: string[] = []; const values: (string | number | null)[] = []; - if (update.title !== undefined) { - sets.push("title = ?"); - values.push(update.title); - } - if (update.description !== undefined) { - sets.push("description = ?"); - values.push(update.description); - } - if (update.category !== undefined) { - sets.push("category = ?"); - values.push(update.category); - } - if (update.categoryLabel !== undefined) { - sets.push("category_label = ?"); - values.push(update.categoryLabel); - } - if (update.status !== undefined) { - sets.push("status = ?"); - values.push(update.status); - } - if (update.outcome !== undefined) { - sets.push("outcome = ?"); - values.push(update.outcome); - } - if (update.resolvedOutcomeId !== undefined) { - sets.push("resolved_outcome_id = ?"); - values.push(update.resolvedOutcomeId); - } - if (update.bettingDeadline !== undefined) { - sets.push("betting_deadline = ?"); - values.push(update.bettingDeadline); - } - if (update.resolutionTime !== undefined) { - sets.push("resolution_time = ?"); - values.push(update.resolutionTime); - } - if (update.xrplMarketTx !== undefined) { - sets.push("xrpl_market_tx = ?"); - values.push(update.xrplMarketTx); - } - if (update.xrplEscrowSequence !== undefined) { - sets.push("xrpl_escrow_sequence = ?"); - values.push(update.xrplEscrowSequence); - } - if (update.xrplEscrowTx !== undefined) { - sets.push("xrpl_escrow_tx = ?"); - values.push(update.xrplEscrowTx); - } - if (update.xrplEscrowFinishTx !== undefined) { - sets.push("xrpl_escrow_finish_tx = ?"); - values.push(update.xrplEscrowFinishTx); - } - if (update.xrplEscrowCancelTx !== undefined) { - sets.push("xrpl_escrow_cancel_tx = ?"); - values.push(update.xrplEscrowCancelTx); - } - if (update.poolTotalDrops !== undefined) { - sets.push("pool_total_drops = ?"); - values.push(update.poolTotalDrops); - } - if (update.yesTotalDrops !== undefined) { - sets.push("yes_total_drops = ?"); - values.push(update.yesTotalDrops); - } - if (update.noTotalDrops !== undefined) { - sets.push("no_total_drops = ?"); - values.push(update.noTotalDrops); - } - if (update.operatorAddress !== undefined) { - sets.push("operator_address = ?"); - values.push(update.operatorAddress); - } - if (update.issuerAddress !== undefined) { - sets.push("issuer_address = ?"); - values.push(update.issuerAddress); - } - - if (sets.length === 0) { - return getMarketById(id); - } + if (update.title !== undefined) { sets.push("title = ?"); values.push(update.title); } + if (update.description !== undefined) { sets.push("description = ?"); values.push(update.description); } + if (update.category !== undefined) { sets.push("category = ?"); values.push(update.category); } + if (update.categoryLabel !== undefined) { sets.push("category_label = ?"); values.push(update.categoryLabel); } + if (update.status !== undefined) { sets.push("status = ?"); values.push(update.status); } + if (update.outcome !== undefined) { sets.push("outcome = ?"); values.push(update.outcome); } + if (update.resolvedOutcomeId !== undefined) { sets.push("resolved_outcome_id = ?"); values.push(update.resolvedOutcomeId); } + if (update.bettingDeadline !== undefined) { sets.push("betting_deadline = ?"); values.push(update.bettingDeadline); } + if (update.resolutionTime !== undefined) { sets.push("resolution_time = ?"); values.push(update.resolutionTime); } + if (update.poolTotalWei !== undefined) { sets.push("pool_total_wei = ?"); values.push(update.poolTotalWei); } + if (update.yesTotalWei !== undefined) { sets.push("yes_total_wei = ?"); values.push(update.yesTotalWei); } + if (update.noTotalWei !== undefined) { sets.push("no_total_wei = ?"); values.push(update.noTotalWei); } + if (update.operatorAddress !== undefined) { sets.push("operator_address = ?"); values.push(update.operatorAddress); } + + if (sets.length === 0) return getMarketById(id); sets.push("updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')"); values.push(id); @@ -307,72 +203,49 @@ export function updateMarket(id: string, update: MarketUpdate): Market | null { return getMarketById(id); } -/** - * Update pool total for a multi-outcome bet. - * Increments the market's pool_total_drops. - */ -export function addToPoolMultiOutcome( - id: string, - amountDrops: string -): void { +export function addToPoolMultiOutcome(id: string, amountWei: string): void { const db = getDb(); db.query( `UPDATE markets SET - pool_total_drops = CAST(CAST(pool_total_drops AS INTEGER) + ? AS TEXT), + pool_total_wei = CAST(CAST(pool_total_wei AS INTEGER) + ? AS TEXT), updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?` - ).run(amountDrops, id); + ).run(amountWei, id); } -/** - * Update pool totals atomically (legacy YES/NO). - */ -export function addToPool( - id: string, - outcome: MarketOutcome, - amountDrops: string -): void { +export function addToPool(id: string, outcome: MarketOutcome, amountWei: string): void { const db = getDb(); - const amount = BigInt(amountDrops); + const amount = BigInt(amountWei); if (outcome === "YES") { db.query( - `UPDATE markets SET - pool_total_drops = CAST(CAST(pool_total_drops AS INTEGER) + ? AS TEXT), - yes_total_drops = CAST(CAST(yes_total_drops AS INTEGER) + ? AS TEXT), + `UPDATE markets SET + pool_total_wei = CAST(CAST(pool_total_wei AS INTEGER) + ? AS TEXT), + yes_total_wei = CAST(CAST(yes_total_wei AS INTEGER) + ? AS TEXT), updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?` ).run(amount.toString(), amount.toString(), id); } else { db.query( - `UPDATE markets SET - pool_total_drops = CAST(CAST(pool_total_drops AS INTEGER) + ? AS TEXT), - no_total_drops = CAST(CAST(no_total_drops AS INTEGER) + ? AS TEXT), + `UPDATE markets SET + pool_total_wei = CAST(CAST(pool_total_wei AS INTEGER) + ? AS TEXT), + no_total_wei = CAST(CAST(no_total_wei AS INTEGER) + ? AS TEXT), updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?` ).run(amount.toString(), amount.toString(), id); } } -/** - * Check if betting is still allowed for a market. - */ export function canPlaceBet(market: Market): boolean { - if (market.status !== "Open") { - return false; - } - const deadline = new Date(market.betting_deadline); - return deadline > new Date(); + if (market.status !== "Open") return false; + return new Date(market.betting_deadline) > new Date(); } -/** - * Get markets that have passed their deadline but are still Open. - */ export function getMarketsToClose(): Market[] { const db = getDb(); return db.query( - `SELECT * FROM markets - WHERE status = 'Open' + `SELECT * FROM markets + WHERE status = 'Open' AND betting_deadline <= strftime('%Y-%m-%dT%H:%M:%fZ','now')` ).all() as Market[]; } diff --git a/apps/api/src/db/models/outcomes.ts b/apps/api/src/db/models/outcomes.ts index fcd2918..6161f99 100644 --- a/apps/api/src/db/models/outcomes.ts +++ b/apps/api/src/db/models/outcomes.ts @@ -10,8 +10,7 @@ export interface Outcome { id: string; market_id: string; label: string; - currency_code: string | null; - total_amount_drops: string; + total_amount_wei: string; display_order: number; created_at: string; } @@ -19,136 +18,75 @@ export interface Outcome { export interface OutcomeInsert { marketId: string; label: string; - currencyCode?: string; displayOrder?: number; } -// ── Currency Code Generation ─────────────────────────────────────── - -/** - * Generate a unique XRPL currency code for an outcome. - * Uses 160-bit (20-byte) non-standard format to include market ID. - * Format: 0x02 prefix + "{shortMarketId}:{outcomeChar}" - * - * Examples: "mlk4:A", "mlk4:B" → unique per market - */ -export function generateCurrencyCode( - marketId: string, - outcomeIndex: number -): string { - // Extract short ID from market ID (e.g., "mkt_mlk4xyz" → "mlk4xyz") - const shortId = marketId.replace("mkt_", "").slice(0, 8); - const outcomeChar = String.fromCharCode(65 + outcomeIndex); // A, B, C, D, E - const label = `${shortId}:${outcomeChar}`; - - // Encode as 20-byte hex (XRPL non-standard currency format) - const buf = Buffer.alloc(20, 0); - buf[0] = 0x02; // non-standard currency marker (required by XRPL) - const encoded = Buffer.from(label, "utf-8"); - encoded.copy(buf, 1, 0, Math.min(encoded.length, 19)); - return buf.toString("hex").toUpperCase(); -} - // ── Queries ──────────────────────────────────────────────────────── -/** - * Create a single outcome. - */ export function createOutcome(outcome: OutcomeInsert): Outcome { const db = getDb(); const id = generateId("out"); db.query( - `INSERT INTO outcomes (id, market_id, label, currency_code, display_order) - VALUES (?, ?, ?, ?, ?)` - ).run( - id, - outcome.marketId, - outcome.label, - outcome.currencyCode ?? null, - outcome.displayOrder ?? 0 - ); + `INSERT INTO outcomes (id, market_id, label, display_order) + VALUES (?, ?, ?, ?)` + ).run(id, outcome.marketId, outcome.label, outcome.displayOrder ?? 0); return getOutcomeById(id)!; } -/** - * Create multiple outcomes for a market in batch. - */ export function createOutcomesBatch( marketId: string, - outcomes: { label: string; currencyCode?: string }[] + outcomes: { label: string }[] ): Outcome[] { const db = getDb(); const results: Outcome[] = []; const stmt = db.query( - `INSERT INTO outcomes (id, market_id, label, currency_code, display_order) - VALUES (?, ?, ?, ?, ?)` + `INSERT INTO outcomes (id, market_id, label, display_order) + VALUES (?, ?, ?, ?)` ); for (let i = 0; i < outcomes.length; i++) { const id = generateId("out"); - const currencyCode = - outcomes[i].currencyCode ?? generateCurrencyCode(marketId, i); - - stmt.run(id, marketId, outcomes[i].label, currencyCode, i); + stmt.run(id, marketId, outcomes[i].label, i); results.push(getOutcomeById(id)!); } return results; } -/** - * Get an outcome by ID. - */ export function getOutcomeById(id: string): Outcome | null { const db = getDb(); return db.query("SELECT * FROM outcomes WHERE id = ?").get(id) as Outcome | null; } -/** - * List all outcomes for a market, ordered by display_order. - */ export function listOutcomesByMarket(marketId: string): Outcome[] { const db = getDb(); return db - .query( - "SELECT * FROM outcomes WHERE market_id = ? ORDER BY display_order ASC" - ) + .query("SELECT * FROM outcomes WHERE market_id = ? ORDER BY display_order ASC") .all(marketId) as Outcome[]; } -/** - * Update the total amount for an outcome (add to existing). - */ -export function addToOutcomeTotal( - id: string, - amountDrops: string -): void { +export function addToOutcomeTotal(id: string, amountWei: string): void { const db = getDb(); db.query( `UPDATE outcomes SET - total_amount_drops = CAST(CAST(total_amount_drops AS INTEGER) + ? AS TEXT) + total_amount_wei = CAST(CAST(total_amount_wei AS INTEGER) + ? AS TEXT) WHERE id = ?` - ).run(amountDrops, id); + ).run(amountWei, id); } -/** - * Calculate probability for each outcome in a market. - * Returns outcomes with a `probability` field (0-100 integer). - */ export function getOutcomesWithProbability( marketId: string ): (Outcome & { probability: number })[] { const outcomes = listOutcomesByMarket(marketId); const totalPool = outcomes.reduce( - (sum, o) => sum + BigInt(o.total_amount_drops), + (sum, o) => sum + BigInt(o.total_amount_wei), 0n ); if (totalPool === 0n) { - // Equal probability when no bets const equalProb = Math.floor(100 / outcomes.length); const remainder = 100 - equalProb * outcomes.length; return outcomes.map((o, i) => ({ @@ -157,30 +95,21 @@ export function getOutcomesWithProbability( })); } - // Calculate proportional probabilities const rawProbs = outcomes.map((o) => ({ outcome: o, - raw: Number((BigInt(o.total_amount_drops) * 10000n) / totalPool) / 100, + raw: Number((BigInt(o.total_amount_wei) * 10000n) / totalPool) / 100, })); - // Round to integers, ensure they sum to 100 const rounded = rawProbs.map((p) => Math.round(p.raw)); const diff = 100 - rounded.reduce((s, v) => s + v, 0); if (diff !== 0) { - // Adjust the largest outcome const maxIdx = rounded.indexOf(Math.max(...rounded)); rounded[maxIdx] += diff; } - return rawProbs.map((p, i) => ({ - ...p.outcome, - probability: rounded[i], - })); + return rawProbs.map((p, i) => ({ ...p.outcome, probability: rounded[i] })); } -/** - * Delete all outcomes for a market (used in cleanup). - */ export function deleteOutcomesByMarket(marketId: string): void { const db = getDb(); db.query("DELETE FROM outcomes WHERE market_id = ?").run(marketId); diff --git a/apps/api/src/db/models/payouts.ts b/apps/api/src/db/models/payouts.ts index 50c2880..82932fa 100644 --- a/apps/api/src/db/models/payouts.ts +++ b/apps/api/src/db/models/payouts.ts @@ -1,6 +1,6 @@ /** * DB model for the payouts table. - * Tracks XRP payouts to winning bettors. + * Tracks ETH payouts to winning bettors. */ import { getDb, generateId } from "../index"; @@ -12,7 +12,7 @@ export interface Payout { id: string; market_id: string; user_id: string; - amount_drops: string; + amount_wei: string; status: PayoutStatus; payout_tx: string | null; created_at: string; @@ -22,7 +22,7 @@ export interface Payout { export interface PayoutInsert { marketId: string; userId: string; - amountDrops: string; + amountWei: string; } export interface PayoutUpdate { @@ -32,53 +32,41 @@ export interface PayoutUpdate { // ── Queries ──────────────────────────────────────────────────────── -/** - * Create a new payout record. - */ export function createPayout(payout: PayoutInsert): Payout { const db = getDb(); const id = generateId("pay"); - + db.query( - `INSERT INTO payouts (id, market_id, user_id, amount_drops, status) + `INSERT INTO payouts (id, market_id, user_id, amount_wei, status) VALUES (?, ?, ?, ?, 'Pending')` - ).run(id, payout.marketId, payout.userId, payout.amountDrops); + ).run(id, payout.marketId, payout.userId, payout.amountWei); return getPayoutById(id)!; } -/** - * Create multiple payouts in a batch. - */ export function createPayoutsBatch(payouts: PayoutInsert[]): Payout[] { const db = getDb(); const results: Payout[] = []; - + const stmt = db.query( - `INSERT INTO payouts (id, market_id, user_id, amount_drops, status) + `INSERT INTO payouts (id, market_id, user_id, amount_wei, status) VALUES (?, ?, ?, ?, 'Pending')` ); for (const payout of payouts) { const id = generateId("pay"); - stmt.run(id, payout.marketId, payout.userId, payout.amountDrops); + stmt.run(id, payout.marketId, payout.userId, payout.amountWei); results.push(getPayoutById(id)!); } return results; } -/** - * Get a payout by ID. - */ export function getPayoutById(id: string): Payout | null { const db = getDb(); return db.query("SELECT * FROM payouts WHERE id = ?").get(id) as Payout | null; } -/** - * List payouts for a market. - */ export function listPayoutsByMarket(marketId: string, status?: PayoutStatus): Payout[] { const db = getDb(); if (status) { @@ -91,9 +79,6 @@ export function listPayoutsByMarket(marketId: string, status?: PayoutStatus): Pa ).all(marketId) as Payout[]; } -/** - * List payouts for a user. - */ export function listPayoutsByUser(userId: string): Payout[] { const db = getDb(); return db.query( @@ -101,36 +86,22 @@ export function listPayoutsByUser(userId: string): Payout[] { ).all(userId) as Payout[]; } -/** - * Get pending payouts for a market (for batch execution). - */ -export function getPendingPayouts(marketId: string, limit: number = 50): Payout[] { +export function getPendingPayouts(marketId: string, limit = 50): Payout[] { const db = getDb(); return db.query( - "SELECT * FROM payouts WHERE market_id = ? AND status = 'Pending' ORDER BY amount_drops DESC LIMIT ?" + "SELECT * FROM payouts WHERE market_id = ? AND status = 'Pending' ORDER BY amount_wei DESC LIMIT ?" ).all(marketId, limit) as Payout[]; } -/** - * Update a payout. - */ export function updatePayout(id: string, update: PayoutUpdate): Payout | null { const db = getDb(); const sets: string[] = []; const values: (string | number | null)[] = []; - if (update.status !== undefined) { - sets.push("status = ?"); - values.push(update.status); - } - if (update.payoutTx !== undefined) { - sets.push("payout_tx = ?"); - values.push(update.payoutTx); - } + if (update.status !== undefined) { sets.push("status = ?"); values.push(update.status); } + if (update.payoutTx !== undefined) { sets.push("payout_tx = ?"); values.push(update.payoutTx); } - if (sets.length === 0) { - return getPayoutById(id); - } + if (sets.length === 0) return getPayoutById(id); sets.push("updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')"); values.push(id); @@ -139,34 +110,31 @@ export function updatePayout(id: string, update: PayoutUpdate): Payout | null { return getPayoutById(id); } -/** - * Get payout stats for a market. - */ export function getPayoutStats(marketId: string): { total: number; pending: number; sent: number; failed: number; - totalDrops: string; - sentDrops: string; + totalWei: string; + sentWei: string; } { const db = getDb(); const result = db.query( - `SELECT + `SELECT COUNT(*) as total, SUM(CASE WHEN status = 'Pending' THEN 1 ELSE 0 END) as pending, SUM(CASE WHEN status = 'Sent' THEN 1 ELSE 0 END) as sent, SUM(CASE WHEN status = 'Failed' THEN 1 ELSE 0 END) as failed, - COALESCE(SUM(CAST(amount_drops AS INTEGER)), 0) as total_drops, - COALESCE(SUM(CASE WHEN status = 'Sent' THEN CAST(amount_drops AS INTEGER) ELSE 0 END), 0) as sent_drops + COALESCE(SUM(CAST(amount_wei AS INTEGER)), 0) as total_wei, + COALESCE(SUM(CASE WHEN status = 'Sent' THEN CAST(amount_wei AS INTEGER) ELSE 0 END), 0) as sent_wei FROM payouts WHERE market_id = ?` ).get(marketId) as { total: number; pending: number; sent: number; failed: number; - total_drops: number; - sent_drops: number; + total_wei: number; + sent_wei: number; }; return { @@ -174,14 +142,11 @@ export function getPayoutStats(marketId: string): { pending: result.pending, sent: result.sent, failed: result.failed, - totalDrops: result.total_drops.toString(), - sentDrops: result.sent_drops.toString(), + totalWei: result.total_wei.toString(), + sentWei: result.sent_wei.toString(), }; } -/** - * Check if user already has a payout for this market. - */ export function payoutExistsForUser(marketId: string, userId: string): boolean { const db = getDb(); const result = db.query( diff --git a/apps/api/src/db/models/system-state.ts b/apps/api/src/db/models/system-state.ts index 70c562a..8fcb557 100644 --- a/apps/api/src/db/models/system-state.ts +++ b/apps/api/src/db/models/system-state.ts @@ -1,7 +1,7 @@ /** * DB model for the system_state key-value table. * - * Used to persist sync cursors (e.g. last_ledger_index) and other + * Used to persist sync cursors (e.g. last_block_number) and other * runtime configuration that must survive restarts. */ import { getDb } from "../index"; diff --git a/apps/api/src/db/models/trades.ts b/apps/api/src/db/models/trades.ts index 8a3b408..0a486f5 100644 --- a/apps/api/src/db/models/trades.ts +++ b/apps/api/src/db/models/trades.ts @@ -1,6 +1,6 @@ /** * DB model for the trades table. - * Tracks DEX trades of outcome tokens. + * Tracks secondary market trades of outcome positions. */ import { getDb, generateId } from "../index"; @@ -13,7 +13,7 @@ export interface Trade { taker_gets: string; taker_pays: string; executed_at: string; - ledger_index: number; + block_number: number; memo_json: string | null; } @@ -23,21 +23,18 @@ export interface TradeInsert { takerGets: string; takerPays: string; executedAt: string; - ledgerIndex: number; + blockNumber: number; memoJson?: string; } // ── Queries ──────────────────────────────────────────────────────── -/** - * Create a new trade record. - */ export function createTrade(trade: TradeInsert): Trade { const db = getDb(); const id = generateId("trd"); - + db.query( - `INSERT INTO trades (id, market_id, offer_tx, taker_gets, taker_pays, executed_at, ledger_index, memo_json) + `INSERT INTO trades (id, market_id, offer_tx, taker_gets, taker_pays, executed_at, block_number, memo_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?)` ).run( id, @@ -46,32 +43,23 @@ export function createTrade(trade: TradeInsert): Trade { trade.takerGets, trade.takerPays, trade.executedAt, - trade.ledgerIndex, + trade.blockNumber, trade.memoJson ?? null ); return getTradeById(id)!; } -/** - * Get a trade by ID. - */ export function getTradeById(id: string): Trade | null { const db = getDb(); return db.query("SELECT * FROM trades WHERE id = ?").get(id) as Trade | null; } -/** - * Get a trade by offer tx hash. - */ export function getTradeByOfferTx(offerTx: string): Trade | null { const db = getDb(); return db.query("SELECT * FROM trades WHERE offer_tx = ?").get(offerTx) as Trade | null; } -/** - * List trades for a market. - */ export function listTradesByMarket(marketId: string): Trade[] { const db = getDb(); return db.query( @@ -79,9 +67,6 @@ export function listTradesByMarket(marketId: string): Trade[] { ).all(marketId) as Trade[]; } -/** - * List trades before a specific timestamp (for payout calculation). - */ export function listTradesBeforeDeadline(marketId: string, deadline: string): Trade[] { const db = getDb(); return db.query( @@ -89,25 +74,19 @@ export function listTradesBeforeDeadline(marketId: string, deadline: string): Tr ).all(marketId, deadline) as Trade[]; } -/** - * Get trade volume for a market. - */ -export function getTradeVolume(marketId: string): { count: number; totalDrops: string } { +export function getTradeVolume(marketId: string): { count: number; totalWei: string } { const db = getDb(); const result = db.query( `SELECT COUNT(*) as count, COALESCE(SUM(CAST(taker_pays AS INTEGER)), 0) as total FROM trades WHERE market_id = ?` ).get(marketId) as { count: number; total: number }; - + return { count: result.count, - totalDrops: result.total.toString(), + totalWei: result.total.toString(), }; } -/** - * Check if trade already exists (by offer tx). - */ export function tradeExists(offerTx: string): boolean { const db = getDb(); const result = db.query( diff --git a/apps/api/src/db/models/users.ts b/apps/api/src/db/models/users.ts index d16a8ea..38129b9 100644 --- a/apps/api/src/db/models/users.ts +++ b/apps/api/src/db/models/users.ts @@ -34,7 +34,7 @@ export function getUserByWallet(address: string): User | null { /** * Create a new user. */ -export function createUser(walletAddress: string, provider: string = "gemwallet"): User { +export function createUser(walletAddress: string, provider: string = "metamask"): User { const db = getDb(); const id = generateId("usr"); @@ -49,7 +49,7 @@ export function createUser(walletAddress: string, provider: string = "gemwallet" * Get or create a user by wallet address. * Returns existing user if found, otherwise creates a new one. */ -export function getOrCreateUser(walletAddress: string, provider: string = "gemwallet"): User { +export function getOrCreateUser(walletAddress: string, provider: string = "metamask"): User { const existing = getUserByWallet(walletAddress); if (existing) { return existing; diff --git a/apps/api/src/db/seed.ts b/apps/api/src/db/seed.ts index 37bd5e6..b3fee9b 100644 --- a/apps/api/src/db/seed.ts +++ b/apps/api/src/db/seed.ts @@ -10,8 +10,7 @@ import { addAttribute } from "./models/user-attributes"; // ── Demo data (from apps/mock/lib/markets-data.ts, XRP amounts) ─── -const DEMO_ISSUER = "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh"; // genesis testnet -const DEMO_OPERATOR = "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh"; +const DEMO_OPERATOR = "0x0000000000000000000000000000000000000001"; // EVM placeholder const DEMO_MARKETS = [ { @@ -151,7 +150,7 @@ async function seed() { createdBy: DEMO_OPERATOR, bettingDeadline: market.bettingDeadline, resolutionTime: market.resolutionTime, - issuerAddress: DEMO_ISSUER, + operatorAddress: DEMO_OPERATOR, outcomes: market.outcomes, }); diff --git a/apps/api/src/evm/client.ts b/apps/api/src/evm/client.ts new file mode 100644 index 0000000..a514b21 --- /dev/null +++ b/apps/api/src/evm/client.ts @@ -0,0 +1,102 @@ +/** + * EVM client utilities for MITATE prediction markets. + * + * Uses viem for type-safe transaction signing and submission. + */ +import { createPublicClient, createWalletClient, http, defineChain } from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +import { config } from "../config"; + +// ── Chain definition ─────────────────────────────────────────────── + +function getChain() { + return defineChain({ + id: config.evmChainId, + name: "EVM", + nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 }, + rpcUrls: { + default: { http: [config.evmRpcUrl] }, + }, + }); +} + +// ── Health ───────────────────────────────────────────────────────── + +export interface EvmHealth { + connected: boolean; + chainId?: number; + blockNumber?: string; + error?: string; +} + +/** + * Get health status of the EVM RPC connection. + */ +export async function getEvmHealth(): Promise { + try { + const client = createPublicClient({ + chain: getChain(), + transport: http(config.evmRpcUrl), + }); + + const [chainId, blockNumber] = await Promise.all([ + client.getChainId(), + client.getBlockNumber(), + ]); + + return { + connected: true, + chainId, + blockNumber: blockNumber.toString(), + }; + } catch (err) { + return { + connected: false, + error: err instanceof Error ? err.message : "Unknown error", + }; + } +} + +// ── Balance ──────────────────────────────────────────────────────── + +/** + * Get ETH balance for an address (in wei). + */ +export async function getEvmBalance(address: `0x${string}`): Promise { + const client = createPublicClient({ + chain: getChain(), + transport: http(config.evmRpcUrl), + }); + return client.getBalance({ address }); +} + +// ── Transaction submission ───────────────────────────────────────── + +/** + * Sign and submit an ETH transfer using the operator wallet. + * Used for server-side payouts. + */ +export async function signAndSubmitWithOperator( + to: `0x${string}`, + valueWei: bigint +): Promise<{ hash: string }> { + if (!config.operatorPrivateKey) { + throw new Error("EVM_OPERATOR_PRIVATE_KEY not configured"); + } + + const account = privateKeyToAccount(config.operatorPrivateKey as `0x${string}`); + const chain = getChain(); + + const walletClient = createWalletClient({ + account, + chain, + transport: http(config.evmRpcUrl), + }); + + const hash = await walletClient.sendTransaction({ + to, + value: valueWei, + }); + + return { hash }; +} diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 847d829..820cbce 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -3,7 +3,6 @@ import { cors } from "hono/cors"; import { logger } from "hono/logger"; import { config, validateConfig } from "./config"; import { initDatabase, closeDatabase } from "./db"; -import { createXrplClient, closeXrplClient, getXrplHealth } from "./xrpl/client"; import healthRoutes from "./routes/health"; import marketsRoutes from "./routes/markets"; import betsRoutes from "./routes/bets"; @@ -39,7 +38,7 @@ app.get("/", (c) => { return c.json({ name: "MITATE API", version: "0.1.0", - description: "XRPL Parimutuel Prediction Market API", + description: "EVM Parimutuel Prediction Market API", }); }); @@ -61,7 +60,6 @@ app.onError((err, c) => { async function start() { console.log("Starting MITATE API..."); - // Validate configuration const configErrors = validateConfig(); if (configErrors.length > 0) { console.error("Configuration errors:"); @@ -71,7 +69,6 @@ async function start() { } } - // Initialize database try { await initDatabase(); console.log("Database initialized"); @@ -80,22 +77,11 @@ async function start() { process.exit(1); } - // Initialize XRPL client - try { - await createXrplClient(); - console.log("XRPL client initialized"); - } catch (err) { - console.error("Failed to initialize XRPL client:", err); - // Don't exit - allow startup without XRPL for development - } - console.log(`MITATE API listening on port ${config.port}`); } -// Graceful shutdown -async function shutdown() { +function shutdown() { console.log("Shutting down..."); - await closeXrplClient(); closeDatabase(); process.exit(0); } @@ -103,7 +89,6 @@ async function shutdown() { process.on("SIGINT", shutdown); process.on("SIGTERM", shutdown); -// Start the server start(); export default { diff --git a/apps/api/src/routes/bets.ts b/apps/api/src/routes/bets.ts index 5cbc123..7c84567 100644 --- a/apps/api/src/routes/bets.ts +++ b/apps/api/src/routes/bets.ts @@ -22,7 +22,7 @@ const bets = new Hono(); const placeBetSchema = z.object({ outcomeId: z.string().min(1), - amountDrops: z.string().regex(/^\d+$/, "Amount must be positive integer string"), + amountWei: z.string().regex(/^\d+$/, "Amount must be positive integer string"), bettorAddress: z.string().min(1), }); @@ -34,7 +34,7 @@ const confirmBetSchema = z.object({ /** * POST /markets/:marketId/bets - Create bet intent - * Returns XRPL tx payloads for TrustSet and Payment + * Returns EVM payment tx for the user to sign and submit */ bets.post("/markets/:marketId/bets", zValidator("json", placeBetSchema), async (c) => { const marketId = c.req.param("marketId"); @@ -50,7 +50,7 @@ bets.post("/markets/:marketId/bets", zValidator("json", placeBetSchema), async ( const result = placeBet({ marketId, outcomeId: body.outcomeId, - amountDrops: body.amountDrops, + amountWei: body.amountWei, userAddress: body.bettorAddress, }); @@ -59,15 +59,12 @@ bets.post("/markets/:marketId/bets", zValidator("json", placeBetSchema), async ( id: result.bet.id, marketId: result.bet.market_id, outcomeId: result.bet.outcome_id, - amountDrops: result.bet.amount_drops, + amountWei: result.bet.amount_wei, status: result.bet.status, }, weightScore: result.weightScore, - effectiveAmountDrops: result.effectiveAmountDrops, - unsignedTx: { - trustSet: result.trustSetTx, - payment: result.paymentTx, - }, + effectiveAmountWei: result.effectiveAmountWei, + unsignedTx: result.paymentTx, }, 201); } catch (err) { const message = err instanceof Error ? err.message : "Failed to create bet"; @@ -82,7 +79,7 @@ bets.post("/markets/:marketId/bets", zValidator("json", placeBetSchema), async ( }); /** - * POST /markets/:marketId/bets/:betId/confirm - Confirm bet after XRPL transaction + * POST /markets/:marketId/bets/:betId/confirm - Confirm bet after EVM transaction */ bets.post("/markets/:marketId/bets/:betId/confirm", zValidator("json", confirmBetSchema), async (c) => { console.log("[confirmBet route] Request received"); @@ -141,9 +138,9 @@ bets.get("/markets/:marketId/bets", async (c) => { outcomeId: bet.outcome_id, outcomeLabel: outcome?.label ?? bet.outcome, bettorAddress: bet.user_id, - amountDrops: bet.amount_drops, + amountWei: bet.amount_wei, weightScore: bet.weight_score, - effectiveAmountDrops: bet.effective_amount_drops, + effectiveAmountWei: bet.effective_amount_wei, txHash: bet.payment_tx, status: bet.status, createdAt: bet.placed_at, @@ -177,13 +174,12 @@ bets.get("/bets/:id", async (c) => { marketId: bet.market_id, outcomeId: bet.outcome_id, outcomeLabel: outcome?.label ?? bet.outcome, - amountDrops: bet.amount_drops, + amountWei: bet.amount_wei, weightScore: bet.weight_score, - effectiveAmountDrops: bet.effective_amount_drops, + effectiveAmountWei: bet.effective_amount_wei, status: bet.status, createdAt: bet.placed_at, paymentTx: bet.payment_tx, - mintTx: bet.mint_tx, payout, }, }); @@ -211,9 +207,9 @@ bets.get("/users/:address/bets", async (c) => { marketTitle: market?.title, outcomeId: bet.outcome_id, outcomeLabel: outcome?.label ?? bet.outcome, - amountDrops: bet.amount_drops, + amountWei: bet.amount_wei, weightScore: bet.weight_score, - effectiveAmountDrops: bet.effective_amount_drops, + effectiveAmountWei: bet.effective_amount_wei, status: bet.status, createdAt: bet.placed_at, payout, @@ -221,15 +217,15 @@ bets.get("/users/:address/bets", async (c) => { }); const totalBets = betsData.length; - const totalAmountDrops = betList.reduce( - (sum, b) => sum + BigInt(b.amount_drops), + const totalAmountWei = betList.reduce( + (sum, b) => sum + BigInt(b.amount_wei), 0n ).toString(); return c.json({ bets: betsData, totalBets, - totalAmountDrops, + totalAmountWei, }); }); @@ -239,14 +235,14 @@ bets.get("/users/:address/bets", async (c) => { bets.get("/markets/:marketId/preview", async (c) => { const marketId = c.req.param("marketId"); const outcomeId = c.req.query("outcomeId"); - const amountDrops = c.req.query("amountDrops"); + const amountWei = c.req.query("amountWei"); const bettorAddress = c.req.query("bettorAddress"); if (!outcomeId) { return c.json({ error: { code: "VALIDATION_ERROR", message: "outcomeId is required" } }, 400); } - if (!amountDrops || !/^\d+$/.test(amountDrops)) { - return c.json({ error: { code: "INVALID_AMOUNT", message: "amountDrops must be positive integer" } }, 400); + if (!amountWei || !/^\d+$/.test(amountWei)) { + return c.json({ error: { code: "INVALID_AMOUNT", message: "amountWei must be positive integer" } }, 400); } const market = getMarket(marketId); @@ -254,11 +250,11 @@ bets.get("/markets/:marketId/preview", async (c) => { return c.json({ error: { code: "MARKET_NOT_FOUND", message: "Market not found" } }, 404); } - const result = calculatePotentialPayout(marketId, outcomeId, amountDrops, bettorAddress); + const result = calculatePotentialPayout(marketId, outcomeId, amountWei, bettorAddress); // Calculate implied odds - const impliedOdds = Number(amountDrops) > 0 - ? (Number(result.potentialPayout) / Number(amountDrops)).toFixed(4) + const impliedOdds = Number(amountWei) > 0 + ? (Number(result.potentialPayout) / Number(amountWei)).toFixed(4) : "0"; return c.json({ diff --git a/apps/api/src/routes/health.ts b/apps/api/src/routes/health.ts index 3278e17..6467edd 100644 --- a/apps/api/src/routes/health.ts +++ b/apps/api/src/routes/health.ts @@ -1,31 +1,28 @@ import { Hono } from "hono"; import { getDb } from "../db"; -import { getXrplHealth } from "../xrpl/client"; +import { getEvmHealth } from "../evm/client"; const app = new Hono(); /** * Health check endpoint. - * Returns 200 if the server is running. */ app.get("/health", async (c) => { - const xrplHealth = await getXrplHealth(); + const evmHealth = await getEvmHealth(); return c.json({ status: "ok", timestamp: new Date().toISOString(), - xrpl: xrplHealth, + evm: evmHealth, }); }); /** * Readiness check endpoint. - * Returns 200 only if all dependencies are ready. */ app.get("/ready", async (c) => { const checks: Record = {}; - // Check database try { const db = getDb(); const result = db.query("SELECT 1 as test").get() as { test: number } | null; @@ -41,14 +38,13 @@ app.get("/ready", async (c) => { }; } - // Check XRPL connection - const xrplHealth = await getXrplHealth(); - if (xrplHealth.connected) { - checks.xrpl = { status: "ok" }; + const evmHealth = await getEvmHealth(); + if (evmHealth.connected) { + checks.evm = { status: "ok" }; } else { - checks.xrpl = { + checks.evm = { status: "error", - message: xrplHealth.error || "Not connected", + message: evmHealth.error || "Not connected", }; } @@ -65,33 +61,20 @@ app.get("/ready", async (c) => { }); /** - * Get XRP balance for an address. - * Proxies the request to XRPL to avoid CORS issues. + * Get ETH balance for an EVM address. + * Proxies to EVM RPC to avoid CORS issues. */ app.get("/balance/:address", async (c) => { const address = c.req.param("address"); - - if (!address || !address.startsWith("r") || address.length < 25) { - return c.json({ error: "Invalid address" }, 400); + + if (!address || !address.startsWith("0x") || address.length !== 42) { + return c.json({ error: "Invalid EVM address" }, 400); } try { - const response = await fetch("https://s.altnet.rippletest.net:51234", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - method: "account_info", - params: [{ account: address, ledger_index: "validated" }], - }), - }); - - const data = await response.json() as { result?: { account_data?: { Balance?: string }; error?: string } }; - - if (data.result?.account_data?.Balance) { - return c.json({ balance: data.result.account_data.Balance }); - } - - return c.json({ error: data.result?.error || "Account not found" }, 404); + const { getEvmBalance } = await import("../evm/client"); + const balanceWei = await getEvmBalance(address as `0x${string}`); + return c.json({ balance: balanceWei.toString() }); } catch (err) { return c.json({ error: "Failed to fetch balance" }, 500); } diff --git a/apps/api/src/routes/markets.ts b/apps/api/src/routes/markets.ts index 80a776f..eb13537 100644 --- a/apps/api/src/routes/markets.ts +++ b/apps/api/src/routes/markets.ts @@ -6,7 +6,6 @@ import { zValidator } from "@hono/zod-validator"; import { z } from "zod"; import { createNewMarket, - confirmMarketCreation, getMarket, getMarketFull, getMarkets, @@ -17,6 +16,7 @@ import { } from "../services/markets"; import { config } from "../config"; import { getOutcomesWithProbability } from "../db/models/outcomes"; +import { signAndSubmitWithOperator } from "../evm/client"; const markets = new Hono(); @@ -36,11 +36,6 @@ const createMarketSchema = z.object({ .optional(), }); -const confirmMarketSchema = z.object({ - escrowTxHash: z.string().min(1), - escrowSequence: z.number().int().positive(), -}); - const updateMarketSchema = z.object({ title: z.string().min(1).max(200).optional(), description: z.string().min(1).max(5000).optional(), @@ -58,9 +53,9 @@ function formatMarketResponse(m: { category_label: string | null; status: string; betting_deadline: string; - pool_total_drops: string; + pool_total_wei: string; created_at: string; - outcomes?: { id: string; label: string; probability: number; total_amount_drops: string }[]; + outcomes?: { id: string; label: string; probability: number; total_amount_wei: string }[]; }) { return { id: m.id, @@ -70,12 +65,12 @@ function formatMarketResponse(m: { categoryLabel: m.category_label, status: m.status, bettingDeadline: m.betting_deadline, - totalPoolDrops: m.pool_total_drops, + totalPoolWei: m.pool_total_wei, outcomes: (m.outcomes ?? []).map((o) => ({ id: o.id, label: o.label, probability: o.probability, - totalAmountDrops: o.total_amount_drops, + totalAmountWei: o.total_amount_wei, })), createdAt: m.created_at, }; @@ -83,22 +78,14 @@ function formatMarketResponse(m: { // ── Routes ───────────────────────────────────────────────────────── -/** - * GET /markets - List markets with optional filters - */ markets.get("/", async (c) => { const status = c.req.query("status"); const category = c.req.query("category"); const marketList = getMarkets(status, category); - return c.json({ - markets: marketList.map(formatMarketResponse), - }); + return c.json({ markets: marketList.map(formatMarketResponse) }); }); -/** - * GET /markets/open - List markets available for betting - */ markets.get("/open", async (c) => { const marketList = getOpenMarketsForBetting(); @@ -110,9 +97,6 @@ markets.get("/open", async (c) => { }); }); -/** - * GET /markets/:id - Get market details with outcomes - */ markets.get("/:id", async (c) => { const id = c.req.param("id"); const market = getMarketFull(id); @@ -123,20 +107,17 @@ markets.get("/:id", async (c) => { return c.json({ ...formatMarketResponse(market), - escrowTxHash: market.xrpl_escrow_tx, - escrowSequence: market.xrpl_escrow_sequence, resolutionTime: market.resolution_time, resolvedOutcomeId: market.resolved_outcome_id, - issuerAddress: market.issuer_address, operatorAddress: market.operator_address, }); }); /** - * POST /markets - Create a new market (admin only) + * POST /markets - Create a new market (admin only). + * Market opens immediately on EVM. */ markets.post("/", zValidator("json", createMarketSchema), async (c) => { - // Check admin auth const adminKey = c.req.header("X-Admin-Key"); if (!adminKey || adminKey !== config.adminApiKey) { return c.json({ error: { code: "AUTH_REQUIRED", message: "Admin authentication required" } }, 401); @@ -147,128 +128,13 @@ markets.post("/", zValidator("json", createMarketSchema), async (c) => { try { const result = await createNewMarket(body, config.operatorAddress); - return c.json({ - ...formatMarketResponse(result.market), - escrowTx: result.escrowTx, - }, 201); + return c.json(formatMarketResponse(result.market), 201); } catch (err) { const message = err instanceof Error ? err.message : "Failed to create market"; return c.json({ error: { code: "VALIDATION_ERROR", message } }, 400); } }); -/** - * POST /markets/:id/confirm - Confirm market creation after XRPL tx - */ -markets.post("/:id/confirm", zValidator("json", confirmMarketSchema), async (c) => { - const adminKey = c.req.header("X-Admin-Key"); - if (!adminKey || adminKey !== config.adminApiKey) { - return c.json({ error: { code: "AUTH_REQUIRED", message: "Admin authentication required" } }, 401); - } - - const id = c.req.param("id"); - const body = c.req.valid("json"); - - try { - const market = await confirmMarketCreation(id, body.escrowTxHash, body.escrowSequence); - if (!market) { - return c.json({ error: { code: "MARKET_NOT_FOUND", message: "Market not found" } }, 404); - } - - return c.json({ - data: { - id: market.id, - status: market.status, - }, - }); - } catch (err) { - const message = err instanceof Error ? err.message : "Failed to confirm market"; - return c.json({ error: { code: "VALIDATION_ERROR", message } }, 400); - } -}); - -/** - * POST /markets/:id/test-open - Open market for testing (skip XRPL escrow) - * WARNING: For development/demo only. Bypasses real escrow creation. - */ -markets.post("/:id/test-open", async (c) => { - const adminKey = c.req.header("X-Admin-Key"); - if (!adminKey || adminKey !== config.adminApiKey) { - return c.json({ error: { code: "AUTH_REQUIRED", message: "Admin authentication required" } }, 401); - } - - const id = c.req.param("id"); - const market = getMarket(id); - - if (!market) { - return c.json({ error: { code: "MARKET_NOT_FOUND", message: "Market not found" } }, 404); - } - - if (market.status !== "Draft") { - return c.json({ error: { code: "VALIDATION_ERROR", message: `Cannot open market in ${market.status} status` } }, 400); - } - - // Import updateMarket from db model - const { updateMarket } = await import("../db/models/markets"); - - // Also set operator/issuer addresses from config if not set - const updates: Record = { status: "Open" }; - if (!market.operator_address && config.operatorAddress) { - updates.operatorAddress = config.operatorAddress; - } - if (!market.issuer_address && config.issuerAddress) { - updates.issuerAddress = config.issuerAddress; - } - - const updated = updateMarket(id, updates); - - return c.json({ - data: { - id: updated?.id, - status: updated?.status, - operatorAddress: updated?.operator_address, - message: "Market opened for testing (no XRPL escrow)", - }, - }); -}); - -/** - * POST /markets/:id/fix-operator - Fix operator address for existing market - * WARNING: For development/demo only. - */ -markets.post("/:id/fix-operator", async (c) => { - const adminKey = c.req.header("X-Admin-Key"); - if (!adminKey || adminKey !== config.adminApiKey) { - return c.json({ error: { code: "AUTH_REQUIRED", message: "Admin authentication required" } }, 401); - } - - if (!config.operatorAddress) { - return c.json({ error: { code: "VALIDATION_ERROR", message: "XRPL_OPERATOR_ADDRESS not configured" } }, 400); - } - - const id = c.req.param("id"); - const market = getMarket(id); - - if (!market) { - return c.json({ error: { code: "MARKET_NOT_FOUND", message: "Market not found" } }, 404); - } - - const { updateMarket } = await import("../db/models/markets"); - const updated = updateMarket(id, { - operatorAddress: config.operatorAddress, - issuerAddress: config.issuerAddress || market.issuer_address, - }); - - return c.json({ - data: { - id: updated?.id, - operatorAddress: updated?.operator_address, - issuerAddress: updated?.issuer_address, - message: "Operator address updated", - }, - }); -}); - /** * PATCH /markets/:id - Update market metadata (admin only) */ @@ -304,7 +170,7 @@ markets.patch("/:id", zValidator("json", updateMarketSchema), async (c) => { /** * POST /markets/:id/resolve - Resolve a market (admin only) - * Creates payout records and auto-executes payouts if operator secret is set + * Creates payout records and auto-executes payouts if operator key is set */ markets.post("/:id/resolve", async (c) => { const adminKey = c.req.header("X-Admin-Key"); @@ -323,8 +189,6 @@ markets.post("/:id/resolve", async (c) => { const { listConfirmedBetsByOutcomeId, getTotalEffectiveAmount } = await import("../db/models/bets"); const { createPayout, updatePayout } = await import("../db/models/payouts"); const { getUserById } = await import("../db/models/users"); - const { buildOutcomePayoutPayment } = await import("../xrpl/tx-builder"); - const { signAndSubmitWithOperator } = await import("../xrpl/client"); const market = getMarket(id); if (!market) { @@ -335,7 +199,6 @@ markets.post("/:id/resolve", async (c) => { return c.json({ error: { code: "VALIDATION_ERROR", message: `Cannot resolve market in ${market.status} status` } }, 400); } - // Update market status const updated = updateMarket(id, { status: "Resolved", resolvedOutcomeId: body.outcomeId, @@ -343,54 +206,39 @@ markets.post("/:id/resolve", async (c) => { console.log("[resolve] Market resolved:", id, "winning outcome:", body.outcomeId); - // Calculate and execute payouts const winningBets = listConfirmedBetsByOutcomeId(id, body.outcomeId); - const totalPool = BigInt(market.pool_total_drops); + const totalPool = BigInt(market.pool_total_wei); const winningTotal = BigInt(getTotalEffectiveAmount(id, body.outcomeId)); - console.log("[resolve] Total pool:", totalPool.toString(), "Winning total:", winningTotal.toString()); - console.log("[resolve] Winning bets:", winningBets.length); - const payoutResults: { betId: string; userId: string; amount: string; txHash?: string; error?: string }[] = []; if (winningTotal > 0n && winningBets.length > 0) { for (const bet of winningBets) { - const betEffective = BigInt(bet.effective_amount_drops ?? bet.amount_drops); + const betEffective = BigInt(bet.effective_amount_wei ?? bet.amount_wei); const payoutAmount = (totalPool * betEffective) / winningTotal; if (payoutAmount <= 0n) continue; - // Get user's wallet address const user = getUserById(bet.user_id); if (!user) { console.error("[resolve] User not found for bet:", bet.id); continue; } - // Create payout record const payout = createPayout({ marketId: id, userId: bet.user_id, - amountDrops: payoutAmount.toString(), + amountWei: payoutAmount.toString(), }); - console.log("[resolve] Created payout:", payout.id, "amount:", payoutAmount.toString(), "to:", user.wallet_address); - - // Auto-execute payout if operator secret is configured - if (config.operatorSecret) { + // Auto-execute payout if operator private key is configured + if (config.operatorPrivateKey) { try { - const payoutTx = buildOutcomePayoutPayment({ - operatorAddress: config.operatorAddress, - destination: user.wallet_address, - amountDrops: payoutAmount.toString(), - marketId: id, - outcomeId: body.outcomeId, - }); - - console.log("[resolve] Executing payout for bet:", bet.id); - const result = await signAndSubmitWithOperator(payoutTx as any); + const result = await signAndSubmitWithOperator( + user.wallet_address as `0x${string}`, + payoutAmount + ); updatePayout(payout.id, { status: "Sent", payoutTx: result.hash }); - console.log("[resolve] Payout successful:", result.hash); payoutResults.push({ betId: bet.id, @@ -409,7 +257,7 @@ markets.post("/:id/resolve", async (c) => { }); } } else { - console.log("[resolve] Skipping auto-payout (no XRPL_OPERATOR_SECRET)"); + console.log("[resolve] Skipping auto-payout (no EVM_OPERATOR_PRIVATE_KEY)"); payoutResults.push({ betId: bet.id, userId: user.wallet_address, @@ -419,8 +267,7 @@ markets.post("/:id/resolve", async (c) => { } } - // Update market to Paid if all payouts sent - if (config.operatorSecret && payoutResults.every(p => p.txHash)) { + if (config.operatorPrivateKey && payoutResults.every((p) => p.txHash)) { updateMarket(id, { status: "Paid" }); } @@ -455,16 +302,10 @@ markets.post("/:id/close", async (c) => { return c.json({ error: { code: "VALIDATION_ERROR", message: `Cannot close market in ${market.status} status` } }, 400); } - // Import updateMarket from db model const { updateMarket } = await import("../db/models/markets"); const updated = updateMarket(id, { status: "Closed" }); - return c.json({ - data: { - id: updated?.id, - status: updated?.status, - }, - }); + return c.json({ data: { id: updated?.id, status: updated?.status } }); }); export default markets; diff --git a/apps/api/src/routes/offers.ts b/apps/api/src/routes/offers.ts index 9dc8d60..22bf58f 100644 --- a/apps/api/src/routes/offers.ts +++ b/apps/api/src/routes/offers.ts @@ -20,7 +20,7 @@ const createOfferSchema = z.object({ outcome: z.enum(["YES", "NO"]), side: z.enum(["buy", "sell"]), tokenAmount: z.string().regex(/^\d+(\.\d+)?$/, "Token amount must be numeric"), - xrpAmountDrops: z.string().regex(/^\d+$/, "XRP amount must be positive integer"), + ethAmountWei: z.string().regex(/^\d+$/, "ETH amount must be positive integer"), userAddress: z.string().min(1), }); @@ -28,7 +28,7 @@ const createOfferSchema = z.object({ /** * POST /markets/:marketId/offers - Create offer intent - * Returns XRPL OfferCreate tx payload + * Returns EVM tx payload for signing */ offers.post("/markets/:marketId/offers", zValidator("json", createOfferSchema), async (c) => { const marketId = c.req.param("marketId"); @@ -46,7 +46,7 @@ offers.post("/markets/:marketId/offers", zValidator("json", createOfferSchema), outcome: body.outcome, side: body.side, tokenAmount: body.tokenAmount, - xrpAmountDrops: body.xrpAmountDrops, + ethAmountWei: body.ethAmountWei, }); return c.json({ @@ -82,11 +82,11 @@ offers.get("/markets/:marketId/trades", async (c) => { takerGets: t.taker_gets, takerPays: t.taker_pays, executedAt: t.executed_at, - ledgerIndex: t.ledger_index, + blockNumber: t.block_number, })), stats: { tradeCount: stats.count, - volumeDrops: stats.volumeDrops, + volumeWei: stats.volumeWei, }, }, }); @@ -111,7 +111,7 @@ offers.get("/trades/:id", async (c) => { takerGets: trade.taker_gets, takerPays: trade.taker_pays, executedAt: trade.executed_at, - ledgerIndex: trade.ledger_index, + blockNumber: trade.block_number, }, }); }); diff --git a/apps/api/src/routes/resolve.ts b/apps/api/src/routes/resolve.ts index 29c40e0..49b5833 100644 --- a/apps/api/src/routes/resolve.ts +++ b/apps/api/src/routes/resolve.ts @@ -1,12 +1,11 @@ /** - * Resolution & Payout API routes. + * Payout API routes. + * Market resolution is handled by routes/markets.ts POST /:id/resolve. */ import { Hono } from "hono"; import { zValidator } from "@hono/zod-validator"; import { z } from "zod"; import { - resolveMarket, - confirmResolution, executePayouts, confirmPayout, getPayoutsForMarket, @@ -21,16 +20,6 @@ const resolve = new Hono(); // ── Schemas ──────────────────────────────────────────────────────── -const resolveMarketSchema = z.object({ - outcome: z.enum(["YES", "NO"]), - action: z.enum(["finish", "cancel"]), -}); - -const confirmResolutionSchema = z.object({ - escrowTxHash: z.string().min(1), - action: z.enum(["finish", "cancel"]), -}); - const executePayoutsSchema = z.object({ batchSize: z.number().int().min(1).max(100).optional(), }); @@ -42,74 +31,9 @@ const confirmPayoutSchema = z.object({ // ── Routes ───────────────────────────────────────────────────────── -/** - * POST /markets/:id/resolve - Initiate market resolution (admin only) - * Returns EscrowFinish or EscrowCancel tx for multi-sign - */ -resolve.post("/markets/:id/resolve", zValidator("json", resolveMarketSchema), async (c) => { - const adminKey = c.req.header("X-Admin-Key"); - if (!adminKey || adminKey !== config.adminApiKey) { - return c.json({ error: { code: "AUTH_REQUIRED", message: "Admin authentication required" } }, 401); - } - - const marketId = c.req.param("id"); - const body = c.req.valid("json"); - - try { - const result = resolveMarket({ - marketId, - outcome: body.outcome, - action: body.action, - }); - - return c.json({ - data: { - marketId: result.market.id, - status: result.market.status, - outcome: result.market.outcome, - escrowTx: result.escrowTx, - payoutsCreated: result.payoutsCreated, - }, - }); - } catch (err) { - const message = err instanceof Error ? err.message : "Failed to resolve market"; - return c.json({ error: { code: "VALIDATION_ERROR", message } }, 400); - } -}); - -/** - * POST /markets/:id/resolve/confirm - Confirm resolution after tx validated - */ -resolve.post("/markets/:id/resolve/confirm", zValidator("json", confirmResolutionSchema), async (c) => { - const adminKey = c.req.header("X-Admin-Key"); - if (!adminKey || adminKey !== config.adminApiKey) { - return c.json({ error: { code: "AUTH_REQUIRED", message: "Admin authentication required" } }, 401); - } - - const marketId = c.req.param("id"); - const body = c.req.valid("json"); - - try { - const market = confirmResolution(marketId, body.escrowTxHash, body.action); - if (!market) { - return c.json({ error: { code: "MARKET_NOT_FOUND", message: "Market not found" } }, 404); - } - - return c.json({ - data: { - marketId: market.id, - status: market.status, - }, - }); - } catch (err) { - const message = err instanceof Error ? err.message : "Failed to confirm resolution"; - return c.json({ error: { code: "VALIDATION_ERROR", message } }, 400); - } -}); - /** * POST /markets/:id/payouts - Execute payouts (admin only) - * Returns Payment tx payloads for batch processing + * Returns EVM payment tx objects for batch processing */ resolve.post("/markets/:id/payouts", zValidator("json", executePayoutsSchema), async (c) => { const adminKey = c.req.header("X-Admin-Key"); @@ -191,7 +115,7 @@ resolve.get("/markets/:id/payouts", async (c) => { payouts: payouts.map((p) => ({ id: p.id, userId: p.user_id, - amountDrops: p.amount_drops, + amountWei: p.amount_wei, status: p.status, payoutTx: p.payout_tx, createdAt: p.created_at, @@ -201,8 +125,8 @@ resolve.get("/markets/:id/payouts", async (c) => { pending: stats.pending, sent: stats.sent, failed: stats.failed, - totalDrops: stats.totalDrops, - sentDrops: stats.sentDrops, + totalWei: stats.totalWei, + sentWei: stats.sentWei, }, }, }); @@ -222,7 +146,7 @@ resolve.get("/users/:address/payouts", async (c) => { id: p.id, marketId: p.market_id, marketTitle: market?.title, - amountDrops: p.amount_drops, + amountWei: p.amount_wei, status: p.status, payoutTx: p.payout_tx, createdAt: p.created_at, diff --git a/apps/api/src/services/bets.ts b/apps/api/src/services/bets.ts index 5226b61..35a7b8b 100644 --- a/apps/api/src/services/bets.ts +++ b/apps/api/src/services/bets.ts @@ -2,15 +2,6 @@ * Bets service - business logic for placing and managing bets. */ import { config } from "../config"; -import { - buildTrustSet, - buildBetPayment, - buildMintPayment, - buildOutcomeBetPayment, - buildOutcomeTrustSet, - buildOutcomeMintPayment, -} from "../xrpl/tx-builder"; -import type { MitateMemoData } from "../xrpl/memo"; import { createBet, getBetById, @@ -32,7 +23,6 @@ import { addToPool, addToPoolMultiOutcome, } from "../db/models/markets"; -import { getEscrowByMarket, addToEscrow } from "../db/models/escrows"; import { getOutcomeById, addToOutcomeTotal, @@ -44,23 +34,20 @@ import { calculateWeightScore, } from "../db/models/user-attributes"; import { getOrCreateUser, getUserById } from "../db/models/users"; -import { signAndSubmitWithIssuer } from "../xrpl/client"; -import type { Payment } from "xrpl"; // ── Types ────────────────────────────────────────────────────────── export interface PlaceBetInput { marketId: string; outcomeId: string; - amountDrops: string; + amountWei: string; userAddress: string; } export interface PlaceBetResult { bet: Bet; weightScore: number; - effectiveAmountDrops: string; - trustSetTx?: unknown; + effectiveAmountWei: string; paymentTx: unknown; } @@ -73,252 +60,96 @@ export interface ConfirmBetInput { /** * Create a bet intent for a multi-outcome market. - * 1. Validate market is open and deadline not passed - * 2. Look up user weight from attributes - * 3. Create pending bet record with weight and effective amount - * 4. Return TrustSet and Payment tx payloads for signing + * Returns an EVM payment tx object for the user to sign and submit. */ export function placeBet(input: PlaceBetInput): PlaceBetResult { - // Validate amount - const amount = BigInt(input.amountDrops); + const amount = BigInt(input.amountWei); if (amount <= 0n) { throw new Error("Bet amount must be positive"); } - // Validate market const market = getMarketById(input.marketId); - if (!market) { - throw new Error("Market not found"); - } - if (!canPlaceBet(market)) { - throw new Error("Market is not accepting bets"); - } + if (!market) throw new Error("Market not found"); + if (!canPlaceBet(market)) throw new Error("Market is not accepting bets"); - // Validate outcome belongs to this market const outcome = getOutcomeById(input.outcomeId); - if (!outcome) { - throw new Error("Outcome not found"); - } - if (outcome.market_id !== input.marketId) { - throw new Error("Outcome does not belong to this market"); - } + if (!outcome) throw new Error("Outcome not found"); + if (outcome.market_id !== input.marketId) throw new Error("Outcome does not belong to this market"); - // Get or create user record for foreign key constraint const user = getOrCreateUser(input.userAddress); - // Calculate weight score from user attributes const attributes = getAttributesForUser(input.userAddress); const weightScore = calculateWeightScore(attributes); - const effectiveAmountDrops = Math.round(Number(input.amountDrops) * weightScore).toString(); - - // Create pending bet - const memo: MitateMemoData = { - v: 1, - type: "bet", - marketId: input.marketId, - outcomeId: input.outcomeId, - amount: input.amountDrops, - timestamp: new Date().toISOString(), - }; + const effectiveAmountWei = Math.round(Number(input.amountWei) * weightScore).toString(); const bet = createBet({ marketId: input.marketId, userId: user.id, - outcome: "YES", // Legacy field - kept for backward compat + outcome: "YES", outcomeId: input.outcomeId, - amountDrops: input.amountDrops, + amountWei: input.amountWei, weightScore, - effectiveAmountDrops, - memoJson: JSON.stringify(memo), + effectiveAmountWei, + memoJson: JSON.stringify({ marketId: input.marketId, outcomeId: input.outcomeId }), }); - // Build TrustSet tx for outcome token - const trustSetTx = outcome.currency_code - ? buildOutcomeTrustSet({ - account: input.userAddress, - issuerAddress: config.issuerAddress, - marketId: input.marketId, - outcomeId: input.outcomeId, - currencyCode: outcome.currency_code, - limitValue: effectiveAmountDrops, - }) - : undefined; - - // Build Payment tx (user pays XRP to operator) - // Use market's stored operator address (set at market creation) const operatorAddress = market.operator_address || config.operatorAddress; - - // Debug logging - console.log("[placeBet] market.operator_address:", market.operator_address); - console.log("[placeBet] config.operatorAddress:", config.operatorAddress); - console.log("[placeBet] using operatorAddress:", operatorAddress); - console.log("[placeBet] user address:", input.userAddress); - + if (!operatorAddress) { throw new Error("Operator address not configured"); } - - // Prevent self-payment (temREDUNDANT error) - if (operatorAddress === input.userAddress) { + + if (operatorAddress.toLowerCase() === input.userAddress.toLowerCase()) { throw new Error("Cannot place bet: operator address cannot be the same as bettor address"); } - const paymentTx = buildOutcomeBetPayment({ - account: input.userAddress, - destination: operatorAddress, - amountDrops: input.amountDrops, - marketId: input.marketId, - outcomeId: input.outcomeId, - }); - - return { - bet, - weightScore, - effectiveAmountDrops, - trustSetTx, - paymentTx, + // EVM payment transaction: user sends ETH to operator + const paymentTx = { + from: input.userAddress, + to: operatorAddress, + value: "0x" + amount.toString(16), }; + + return { bet, weightScore, effectiveAmountWei, paymentTx }; } /** - * Confirm a bet after payment is validated on ledger. - * 1. Verify payment tx on XRPL - * 2. Update pool totals (market + outcome) - * 3. Queue token minting (via worker) + * Confirm a bet after payment tx is submitted on-chain. */ export async function confirmBet(input: ConfirmBetInput): Promise { const bet = getBetById(input.betId); - if (!bet) { - throw new Error("Bet not found"); - } - if (bet.status !== "Pending") { - throw new Error(`Bet is already ${bet.status}`); - } + if (!bet) throw new Error("Bet not found"); + if (bet.status !== "Pending") throw new Error(`Bet is already ${bet.status}`); - // Check if tx hash is already used const existingBet = getBetByPaymentTx(input.paymentTxHash); - if (existingBet) { - throw new Error("Payment tx already used for another bet"); - } + if (existingBet) throw new Error("Payment tx already used for another bet"); const market = getMarketById(bet.market_id); - if (!market) { - throw new Error("Market not found"); - } + if (!market) throw new Error("Market not found"); - // Update bet with payment tx updateBet(bet.id, { status: "Confirmed", paymentTx: input.paymentTxHash, }); - // Update market pool total - const effectiveAmount = bet.effective_amount_drops ?? bet.amount_drops; + const effectiveAmount = bet.effective_amount_wei ?? bet.amount_wei; addToPoolMultiOutcome(market.id, effectiveAmount); - // Update outcome total if (bet.outcome_id) { addToOutcomeTotal(bet.outcome_id, effectiveAmount); } - // Update escrow tracking (if exists) - const escrow = getEscrowByMarket(market.id); - if (escrow) { - addToEscrow(escrow.id, bet.amount_drops); - } - - // Auto-mint position tokens if issuer secret is configured - console.log("[confirmBet] Checking issuer secret...", config.issuerSecret ? "SET" : "NOT SET"); - if (config.issuerSecret) { - try { - const mintTx = buildMintTx(bet.id); - console.log("[confirmBet] mintTx built:", mintTx ? "YES" : "NO"); - if (mintTx) { - console.log("[confirmBet] Auto-minting tokens for bet:", bet.id); - const result = await signAndSubmitWithIssuer(mintTx as Payment); - updateBet(bet.id, { mintTx: result.hash }); - console.log("[confirmBet] Mint successful:", result.hash); - } - } catch (err) { - // Log error but don't fail the bet confirmation - console.error("[confirmBet] Auto-mint failed:", err); - // Bet is still confirmed, tokens can be minted manually later - } - } else { - console.log("[confirmBet] Skipping auto-mint (no XRPL_ISSUER_SECRET)"); - } - return getBetById(bet.id)!; } -/** - * Build token mint transaction for a confirmed bet. - * Called by worker after bet is confirmed. - */ -export function buildMintTx(betId: string): unknown | null { - const bet = getBetById(betId); - if (!bet || bet.status !== "Confirmed" || bet.mint_tx) { - return null; - } - - // Get user's wallet address (bet.user_id is the DB user ID, not the XRPL address) - const user = getUserById(bet.user_id); - if (!user) { - console.error("[buildMintTx] User not found:", bet.user_id); - return null; - } - const destinationAddress = user.wallet_address; - console.log("[buildMintTx] Destination address:", destinationAddress); - - // Multi-outcome: use outcome currency code - if (bet.outcome_id) { - const outcome = getOutcomeById(bet.outcome_id); - if (outcome?.currency_code) { - return buildOutcomeMintPayment({ - issuerAddress: config.issuerAddress, - destination: destinationAddress, - marketId: bet.market_id, - outcomeId: bet.outcome_id, - currencyCode: outcome.currency_code, - tokenValue: bet.effective_amount_drops ?? bet.amount_drops, - }); - } - } - - // Legacy YES/NO fallback - return buildMintPayment({ - issuerAddress: config.issuerAddress, - destination: destinationAddress, - marketId: bet.market_id, - outcome: bet.outcome as BetOutcome, - tokenValue: bet.amount_drops, - }); -} - -/** - * Mark bet as minted after token tx is confirmed. - */ -export function markBetMinted(betId: string, mintTxHash: string): Bet | null { - return updateBet(betId, { mintTx: mintTxHash }); -} - -/** - * Mark bet as failed. - */ export function markBetFailed(betId: string): Bet | null { return updateBet(betId, { status: "Failed" }); } -/** - * Get a single bet. - */ export function getBet(id: string): Bet | null { return getBetById(id); } -/** - * Get bets for a market. - */ export function getBetsForMarket(marketId: string, status?: string): Bet[] { if (status && ["Pending", "Confirmed", "Failed", "Refunded"].includes(status)) { return listBetsByMarket(marketId, status as Bet["status"]); @@ -326,49 +157,36 @@ export function getBetsForMarket(marketId: string, status?: string): Bet[] { return listBetsByMarket(marketId); } -/** - * Get bets for a user. - */ export function getBetsForUser(userId: string): Bet[] { return listBetsByUser(userId); } -/** - * Calculate potential payout for a multi-outcome bet. - * Uses effective amounts (with weight applied). - */ export function calculatePotentialPayout( marketId: string, outcomeId: string, - amountDrops: string, + amountWei: string, userAddress?: string ): { potentialPayout: string; weightScore: number; effectiveAmount: string } { const market = getMarketById(marketId); if (!market) { - return { potentialPayout: "0", weightScore: 1.0, effectiveAmount: amountDrops }; + return { potentialPayout: "0", weightScore: 1.0, effectiveAmount: amountWei }; } - // Calculate weight let weightScore = 1.0; if (userAddress) { const attributes = getAttributesForUser(userAddress); weightScore = calculateWeightScore(attributes); } - const effectiveAmount = Math.round(Number(amountDrops) * weightScore).toString(); + const effectiveAmount = Math.round(Number(amountWei) * weightScore).toString(); - // Get outcomes for probability calculation const outcomes = getOutcomesWithProbability(marketId); const targetOutcome = outcomes.find((o) => o.id === outcomeId); if (!targetOutcome) { return { potentialPayout: "0", weightScore, effectiveAmount }; } - // Calculate pool-based payout - const totalPool = outcomes.reduce( - (sum, o) => sum + BigInt(o.total_amount_drops), - 0n - ); - const outcomeTotal = BigInt(targetOutcome.total_amount_drops); + const totalPool = outcomes.reduce((sum, o) => sum + BigInt(o.total_amount_wei), 0n); + const outcomeTotal = BigInt(targetOutcome.total_amount_wei); const betEffective = BigInt(effectiveAmount); const newTotal = totalPool + betEffective; @@ -382,39 +200,27 @@ export function calculatePotentialPayout( return { potentialPayout: payout.toString(), weightScore, effectiveAmount }; } -/** - * Calculate actual payout for a resolved market (multi-outcome). - */ export function calculateActualPayout(bet: Bet): string { const market = getMarketById(bet.market_id); - if (!market || market.status !== "Resolved") { - return "0"; - } + if (!market || market.status !== "Resolved") return "0"; - // Check if this bet's outcome won const resolvedOutcomeId = market.resolved_outcome_id; if (!resolvedOutcomeId) { - // Legacy YES/NO resolution - if (!market.outcome || bet.outcome !== market.outcome) { - return "0"; - } - const totalPool = BigInt(market.pool_total_drops); + if (!market.outcome || bet.outcome !== market.outcome) return "0"; + const totalPool = BigInt(market.pool_total_wei); const winningTotal = BigInt( - market.outcome === "YES" ? market.yes_total_drops : market.no_total_drops + market.outcome === "YES" ? market.yes_total_wei : market.no_total_wei ); - const betAmount = BigInt(bet.amount_drops); + const betAmount = BigInt(bet.amount_wei); if (winningTotal === 0n) return "0"; return ((totalPool * betAmount) / winningTotal).toString(); } - // Multi-outcome resolution - if (bet.outcome_id !== resolvedOutcomeId) { - return "0"; - } + if (bet.outcome_id !== resolvedOutcomeId) return "0"; - const totalPool = BigInt(market.pool_total_drops); + const totalPool = BigInt(market.pool_total_wei); const winningTotal = BigInt(getTotalEffectiveAmount(market.id, resolvedOutcomeId)); - const betEffective = BigInt(bet.effective_amount_drops ?? bet.amount_drops); + const betEffective = BigInt(bet.effective_amount_wei ?? bet.amount_wei); if (winningTotal === 0n) return "0"; return ((totalPool * betEffective) / winningTotal).toString(); diff --git a/apps/api/src/services/markets.ts b/apps/api/src/services/markets.ts index 22090b7..b9a0460 100644 --- a/apps/api/src/services/markets.ts +++ b/apps/api/src/services/markets.ts @@ -2,9 +2,6 @@ * Market service - business logic for market creation and management. */ import { config } from "../config"; -import { buildEscrowCreate } from "../xrpl/tx-builder"; -import { getTransaction } from "../xrpl/client"; -import type { MitateMemoData } from "../xrpl/memo"; import { createMarket, createMarketWithOutcomes, @@ -21,7 +18,6 @@ import { type MarketWithOutcomes, type MarketStatus, } from "../db/models/markets"; -import { createEscrow } from "../db/models/escrows"; // ── Types ────────────────────────────────────────────────────────── @@ -37,34 +33,28 @@ export interface CreateMarketInput { export interface CreateMarketResult { market: MarketWithOutcomes; - escrowTx?: unknown; } // ── Service Functions ────────────────────────────────────────────── /** * Create a new market with outcomes. - * 1. Create DB record in Draft status with outcomes - * 2. Build XRPL EscrowCreate tx for initial pool - * 3. Return market and tx payload for signing + * Markets open immediately on EVM (no escrow tx needed). */ export async function createNewMarket( input: CreateMarketInput, creatorAddress: string ): Promise { - // Validate deadline is in the future const deadline = new Date(input.bettingDeadline); if (deadline <= new Date()) { throw new Error("Betting deadline must be in the future"); } - // Validate outcomes const outcomes = input.outcomes ?? [{ label: "YES" }, { label: "NO" }]; if (outcomes.length < 2 || outcomes.length > 5) { throw new Error("Markets must have 2-5 outcomes"); } - // Create market record with outcomes const marketData = { title: input.title, description: input.description, @@ -73,102 +63,26 @@ export async function createNewMarket( createdBy: creatorAddress, bettingDeadline: input.bettingDeadline, resolutionTime: input.resolutionTime, - issuerAddress: config.issuerAddress, operatorAddress: config.operatorAddress, outcomes, }; const market = createMarketWithOutcomes(marketData); - // Build initial escrow creation tx - const rippleEpochOffset = 946684800; - const cancelAfter = Math.floor(deadline.getTime() / 1000) - rippleEpochOffset; + // Auto-open: no blockchain tx needed on EVM + const opened = updateMarket(market.id, { status: "Open" }); - const escrowTx = buildEscrowCreate({ - account: config.operatorAddress, - amountDrops: "1", - cancelAfter, - marketId: market.id, - destinationTag: parseInt(market.id.replace("mkt_", ""), 36) % 4294967295, - }); - - return { - market, - escrowTx, - }; + return { market: getMarketWithOutcomes(opened!.id)! }; } -/** - * Confirm market creation after XRPL tx is validated. - * Transitions market from Draft to Open. - * If escrowSequence is 0 or 1, fetches it from XRPL. - */ -export async function confirmMarketCreation( - marketId: string, - escrowTxHash: string, - escrowSequence: number -): Promise { - const market = getMarketById(marketId); - if (!market) { - throw new Error("Market not found"); - } - if (market.status !== "Draft") { - throw new Error(`Cannot confirm market in ${market.status} status`); - } - - // Fetch sequence from XRPL if not provided properly - let finalSequence = escrowSequence; - if (escrowSequence <= 1) { - try { - const txData = await getTransaction(escrowTxHash); - if (txData?.Sequence) { - finalSequence = txData.Sequence; - } - // Verify transaction was successful - if (txData?.meta?.TransactionResult !== "tesSUCCESS") { - throw new Error(`Transaction failed: ${txData?.meta?.TransactionResult}`); - } - } catch (err) { - console.warn("Could not fetch tx sequence from XRPL, using provided:", err); - // Continue with provided sequence - } - } - - // Create escrow record - const deadline = new Date(market.betting_deadline); - createEscrow({ - marketId: market.id, - amountDrops: "1", - sequence: finalSequence, - createTx: escrowTxHash, - cancelAfter: Math.floor(deadline.getTime() / 1000), - }); - - // Update market to Open - return updateMarket(marketId, { - status: "Open", - xrplEscrowTx: escrowTxHash, - xrplEscrowSequence: finalSequence, - }); -} - -/** - * Get a single market by ID (without outcomes). - */ export function getMarket(id: string): Market | null { return getMarketById(id); } -/** - * Get a single market with outcomes and probabilities. - */ export function getMarketFull(id: string): MarketWithOutcomes | null { return getMarketWithOutcomes(id); } -/** - * List all markets with outcomes. - */ export function getMarkets(status?: string, category?: string): MarketWithOutcomes[] { const filters: { status?: MarketStatus; category?: string } = {}; @@ -182,59 +96,40 @@ export function getMarkets(status?: string, category?: string): MarketWithOutcom return listMarketsWithOutcomes(Object.keys(filters).length > 0 ? filters : undefined); } -/** - * List markets available for betting. - */ export function getOpenMarketsForBetting(): Market[] { return listOpenMarkets(); } -/** - * Update market metadata (admin only). - */ export function updateMarketMetadata( id: string, update: Partial> ): Market | null { const market = getMarketById(id); - if (!market) { - throw new Error("Market not found"); - } + if (!market) throw new Error("Market not found"); if (market.status !== "Draft" && market.status !== "Open") { throw new Error(`Cannot update market in ${market.status} status`); } return updateMarket(id, update); } -/** - * Close markets that have passed their betting deadline. - * Called periodically by worker. - */ export function closeExpiredMarkets(): Market[] { const markets = getMarketsToClose(); const closed: Market[] = []; for (const market of markets) { const updated = updateMarket(market.id, { status: "Closed" }); - if (updated) { - closed.push(updated); - } + if (updated) closed.push(updated); } return closed; } -/** - * Calculate odds for a market (legacy YES/NO). - */ export function calculateOdds(market: Market): { yes: number; no: number } { - const yesTotal = BigInt(market.yes_total_drops); - const noTotal = BigInt(market.no_total_drops); + const yesTotal = BigInt(market.yes_total_wei); + const noTotal = BigInt(market.no_total_wei); const total = yesTotal + noTotal; - if (total === 0n) { - return { yes: 0.5, no: 0.5 }; - } + if (total === 0n) return { yes: 0.5, no: 0.5 }; return { yes: Number(yesTotal) / Number(total), @@ -242,10 +137,6 @@ export function calculateOdds(market: Market): { yes: number; no: number } { }; } -/** - * Calculate implied price from odds (legacy YES/NO). - */ export function calculatePrice(market: Market): { yes: number; no: number } { - const odds = calculateOdds(market); - return odds; + return calculateOdds(market); } diff --git a/apps/api/src/services/payouts.ts b/apps/api/src/services/payouts.ts index 09014dd..208235b 100644 --- a/apps/api/src/services/payouts.ts +++ b/apps/api/src/services/payouts.ts @@ -2,7 +2,6 @@ * Payouts service - resolution and payout distribution. */ import { config } from "../config"; -import { buildEscrowFinish, buildEscrowCancel, buildPayoutPayment } from "../xrpl/tx-builder"; import { getMarketById, updateMarket, @@ -10,7 +9,6 @@ import { type MarketOutcome, } from "../db/models/markets"; import { listConfirmedBetsByOutcome, type Bet } from "../db/models/bets"; -import { getEscrowByMarket, updateEscrow } from "../db/models/escrows"; import { createPayout, createPayoutsBatch, @@ -34,7 +32,6 @@ export interface ResolveMarketInput { export interface ResolveMarketResult { market: Market; - escrowTx: unknown; payoutsCreated?: number; } @@ -47,7 +44,7 @@ export interface ExecutePayoutsResult { payouts: Array<{ id: string; userId: string; - amountDrops: string; + amountWei: string; payoutTx: unknown; }>; } @@ -61,136 +58,68 @@ export interface PayoutCalculation { // ── Service Functions ────────────────────────────────────────────── /** - * Resolve a market - finish escrow and prepare payouts, or cancel and refund. + * Resolve a market and prepare payouts, or cancel and refund. */ export function resolveMarket(input: ResolveMarketInput): ResolveMarketResult { const market = getMarketById(input.marketId); - if (!market) { - throw new Error("Market not found"); - } + if (!market) throw new Error("Market not found"); - // Only Closed markets can be resolved if (market.status !== "Closed") { throw new Error(`Cannot resolve market in ${market.status} status`); } - const escrow = getEscrowByMarket(input.marketId); - if (!escrow) { - throw new Error("No escrow found for market"); - } - - let escrowTx: unknown; let payoutsCreated = 0; if (input.action === "finish") { - // Build EscrowFinish tx for multi-sign - escrowTx = buildEscrowFinish({ - account: config.operatorAddress, - offerSequence: escrow.sequence, - marketId: input.marketId, - outcome: input.outcome, - }); - - // Calculate and create payout records const payoutCalcs = calculatePayouts(input.marketId, input.outcome); if (payoutCalcs.length > 0) { const payoutInserts = payoutCalcs.map((p) => ({ marketId: input.marketId, userId: p.userId, - amountDrops: p.payoutAmount, + amountWei: p.payoutAmount, })); createPayoutsBatch(payoutInserts); payoutsCreated = payoutCalcs.length; } - // Update market status - updateMarket(input.marketId, { - status: "Resolved", - outcome: input.outcome, - }); - + updateMarket(input.marketId, { status: "Resolved", outcome: input.outcome }); } else { - // Cancel - build EscrowCancel tx - escrowTx = buildEscrowCancel({ - account: config.operatorAddress, - offerSequence: escrow.sequence, - marketId: input.marketId, - }); - - // Update market status - updateMarket(input.marketId, { - status: "Canceled", - }); + updateMarket(input.marketId, { status: "Canceled" }); } return { market: getMarketById(input.marketId)!, - escrowTx, payoutsCreated, }; } -/** - * Confirm resolution after EscrowFinish/Cancel tx is validated. - */ -export function confirmResolution( - marketId: string, - escrowTxHash: string, - action: "finish" | "cancel" -): Market | null { - const market = getMarketById(marketId); - if (!market) { - throw new Error("Market not found"); - } - - const escrow = getEscrowByMarket(marketId); - if (escrow) { - if (action === "finish") { - updateEscrow(escrow.id, { status: "Finished", finishTx: escrowTxHash }); - updateMarket(marketId, { xrplEscrowFinishTx: escrowTxHash }); - } else { - updateEscrow(escrow.id, { status: "Canceled", cancelTx: escrowTxHash }); - updateMarket(marketId, { xrplEscrowCancelTx: escrowTxHash }); - } - } - - return getMarketById(marketId); -} - /** * Calculate payouts for winning bettors. - * Formula: payout = totalPool * userBet / totalWinningBets */ export function calculatePayouts( marketId: string, winningOutcome: MarketOutcome ): PayoutCalculation[] { const market = getMarketById(marketId); - if (!market) { - return []; - } + if (!market) return []; - const totalPool = BigInt(market.pool_total_drops); + const totalPool = BigInt(market.pool_total_wei); const winningTotal = BigInt( - winningOutcome === "YES" ? market.yes_total_drops : market.no_total_drops + winningOutcome === "YES" ? market.yes_total_wei : market.no_total_wei ); - if (winningTotal === 0n) { - return []; - } + if (winningTotal === 0n) return []; - // Get all winning bets const winningBets = listConfirmedBetsByOutcome(marketId, winningOutcome); const payouts: PayoutCalculation[] = []; for (const bet of winningBets) { - const betAmount = BigInt(bet.amount_drops); - // Integer division - floor rounding + const betAmount = BigInt(bet.amount_wei); const payoutAmount = (totalPool * betAmount) / winningTotal; payouts.push({ userId: bet.user_id, - betAmount: bet.amount_drops, + betAmount: bet.amount_wei, payoutAmount: payoutAmount.toString(), }); } @@ -200,21 +129,17 @@ export function calculatePayouts( /** * Execute pending payouts in batches. - * Returns Payment tx payloads for each payout. + * Returns EVM payment tx objects for each payout. */ export function executePayouts(input: ExecutePayoutsInput): ExecutePayoutsResult { const market = getMarketById(input.marketId); - if (!market) { - throw new Error("Market not found"); - } + if (!market) throw new Error("Market not found"); if (market.status !== "Resolved") { throw new Error(`Cannot execute payouts for market in ${market.status} status`); } - if (!market.outcome) { - throw new Error("Market has no resolved outcome"); - } + if (!market.outcome) throw new Error("Market has no resolved outcome"); const batchSize = input.batchSize ?? 50; const pendingPayouts = getPendingPayouts(input.marketId, batchSize); @@ -222,18 +147,17 @@ export function executePayouts(input: ExecutePayoutsInput): ExecutePayoutsResult const results: ExecutePayoutsResult["payouts"] = []; for (const payout of pendingPayouts) { - const payoutTx = buildPayoutPayment({ - operatorAddress: config.operatorAddress, - destination: payout.user_id, - amountDrops: payout.amount_drops, - marketId: input.marketId, - outcome: market.outcome, - }); + // EVM payment tx: operator sends ETH to winner + const payoutTx = { + from: market.operator_address || config.operatorAddress, + to: payout.user_id, + value: "0x" + BigInt(payout.amount_wei).toString(16), + }; results.push({ id: payout.id, userId: payout.user_id, - amountDrops: payout.amount_drops, + amountWei: payout.amount_wei, payoutTx, }); } @@ -241,26 +165,14 @@ export function executePayouts(input: ExecutePayoutsInput): ExecutePayoutsResult return { payouts: results }; } -/** - * Confirm a payout after Payment tx is validated. - */ export function confirmPayout(payoutId: string, txHash: string): Payout | null { - return updatePayout(payoutId, { - status: "Sent", - payoutTx: txHash, - }); + return updatePayout(payoutId, { status: "Sent", payoutTx: txHash }); } -/** - * Mark a payout as failed. - */ export function markPayoutFailed(payoutId: string): Payout | null { return updatePayout(payoutId, { status: "Failed" }); } -/** - * Get payouts for a market. - */ export function getPayoutsForMarket(marketId: string, status?: string): Payout[] { if (status && ["Pending", "Sent", "Failed"].includes(status)) { return listPayoutsByMarket(marketId, status as Payout["status"]); @@ -268,31 +180,19 @@ export function getPayoutsForMarket(marketId: string, status?: string): Payout[] return listPayoutsByMarket(marketId); } -/** - * Get payouts for a user. - */ export function getPayoutsForUser(userId: string): Payout[] { return listPayoutsByUser(userId); } -/** - * Get payout statistics for a market. - */ export function getMarketPayoutStats(marketId: string) { return getPayoutStats(marketId); } -/** - * Check if all payouts are complete. - */ export function arePayoutsComplete(marketId: string): boolean { const stats = getPayoutStats(marketId); return stats.pending === 0 && stats.failed === 0; } -/** - * Mark market as fully paid if all payouts complete. - */ export function finalizeMarketIfComplete(marketId: string): Market | null { if (arePayoutsComplete(marketId)) { return updateMarket(marketId, { status: "Paid" }); diff --git a/apps/api/src/services/trades.ts b/apps/api/src/services/trades.ts index 0a2aedd..3407177 100644 --- a/apps/api/src/services/trades.ts +++ b/apps/api/src/services/trades.ts @@ -1,8 +1,7 @@ /** - * Trades service - DEX trading for outcome tokens. + * Trades service - secondary market trading for outcome tokens. + * On EVM this is simplified; primary flow is direct ETH bets. */ -import { config } from "../config"; -import { buildOfferCreate } from "../xrpl/tx-builder"; import { getMarketById, canPlaceBet } from "../db/models/markets"; import { createTrade, @@ -24,7 +23,7 @@ export interface CreateOfferInput { outcome: "YES" | "NO"; side: "buy" | "sell"; tokenAmount: string; - xrpAmountDrops: string; + ethAmountWei: string; } export interface CreateOfferResult { @@ -34,106 +33,52 @@ export interface CreateOfferResult { // ── Service Functions ────────────────────────────────────────────── /** - * Create an offer to trade outcome tokens on the DEX. - * Returns the OfferCreate tx payload for signing. + * Create an offer to trade outcome positions. + * Returns an EVM tx payload for the user to sign. */ export function createOffer(input: CreateOfferInput): CreateOfferResult { const market = getMarketById(input.marketId); - if (!market) { - throw new Error("Market not found"); - } + if (!market) throw new Error("Market not found"); - // Only allow trading while market is Open if (market.status !== "Open") { throw new Error(`Cannot trade on market in ${market.status} status`); } - // Calculate expiration (betting deadline in Ripple epoch) - const deadline = new Date(market.betting_deadline); - const rippleEpochOffset = 946684800; - const expiration = Math.floor(deadline.getTime() / 1000) - rippleEpochOffset; - - let offerTx: unknown; - - if (input.side === "sell") { - // Selling tokens for XRP - offerTx = buildOfferCreate({ - account: input.userAddress, - issuerAddress: config.issuerAddress, - marketId: input.marketId, - outcome: input.outcome, - takerGetsTokenValue: input.tokenAmount, - takerPaysDrops: input.xrpAmountDrops, - expiration, - }); - } else { - // Buying tokens with XRP - // Note: For buy orders, we need to swap TakerGets/TakerPays - // This is a simplified version - in production would need more complex logic - offerTx = { - TransactionType: "OfferCreate", - Account: input.userAddress, - TakerGets: input.xrpAmountDrops, - TakerPays: { - currency: `02${Buffer.from(`${input.marketId}:${input.outcome}`).toString("hex").toUpperCase()}`.slice(0, 40).padEnd(40, "0"), - issuer: config.issuerAddress, - value: input.tokenAmount, - }, - Expiration: expiration, - }; - } + // EVM offer: a simple ETH transfer representing a trade intent + const offerTx = { + from: input.userAddress, + to: market.operator_address, + value: "0x" + BigInt(input.ethAmountWei).toString(16), + data: "0x" + Buffer.from( + JSON.stringify({ type: "offer", marketId: input.marketId, outcome: input.outcome, side: input.side, tokenAmount: input.tokenAmount }) + ).toString("hex"), + }; return { offerTx }; } -/** - * Record a trade from ledger events. - * Called by the ledger sync worker. - */ export function recordTrade(trade: TradeInsert): Trade | null { - // Check if already recorded (idempotent) if (tradeExists(trade.offerTx)) { return getTradeByOfferTx(trade.offerTx); } - return createTrade(trade); } -/** - * Get trades for a market. - */ export function getTradesForMarket(marketId: string): Trade[] { return listTradesByMarket(marketId); } -/** - * Get valid trades (before deadline) for payout calculation. - */ export function getValidTradesForPayout(marketId: string): Trade[] { const market = getMarketById(marketId); - if (!market) { - return []; - } + if (!market) return []; return listTradesBeforeDeadline(marketId, market.betting_deadline); } -/** - * Get trade statistics for a market. - */ -export function getTradeStats(marketId: string): { - count: number; - volumeDrops: string; -} { +export function getTradeStats(marketId: string): { count: number; volumeWei: string } { const volume = getTradeVolume(marketId); - return { - count: volume.count, - volumeDrops: volume.totalDrops, - }; + return { count: volume.count, volumeWei: volume.totalWei }; } -/** - * Get a single trade. - */ export function getTrade(id: string): Trade | null { return getTradeById(id); } diff --git a/apps/api/src/xrpl/client.ts b/apps/api/src/xrpl/client.ts deleted file mode 100644 index cef2687..0000000 --- a/apps/api/src/xrpl/client.ts +++ /dev/null @@ -1,244 +0,0 @@ -import { Client, Wallet, type ServerInfoResponse, type SubmittableTransaction } from "xrpl"; -import { config } from "../config"; - -let wsClient: Client | null = null; - -export interface XrplHealth { - connected: boolean; - networkId?: number; - ledgerIndex?: number; - serverState?: string; - error?: string; -} - -/** - * Create and connect the XRPL WebSocket client. - */ -export async function createXrplClient(): Promise { - if (wsClient?.isConnected()) { - return wsClient; - } - - wsClient = new Client(config.xrplWsUrl); - - wsClient.on("error", (error) => { - console.error("XRPL client error:", error); - }); - - wsClient.on("disconnected", (code) => { - console.warn(`XRPL client disconnected with code ${code}`); - }); - - wsClient.on("connected", () => { - console.log("XRPL client connected"); - }); - - await wsClient.connect(); - return wsClient; -} - -/** - * Get the current XRPL WebSocket client. - * Returns null if not connected. - */ -export function getXrplClient(): Client | null { - return wsClient?.isConnected() ? wsClient : null; -} - -/** - * Close the XRPL WebSocket client. - */ -export async function closeXrplClient(): Promise { - if (wsClient) { - await wsClient.disconnect(); - wsClient = null; - } -} - -/** - * Get the health status of the XRPL connection. - */ -export async function getXrplHealth(): Promise { - if (!wsClient || !wsClient.isConnected()) { - return { - connected: false, - error: "Client not connected", - }; - } - - try { - const response = (await wsClient.request({ - command: "server_info", - })) as ServerInfoResponse; - - const info = response.result.info; - - return { - connected: true, - networkId: info.network_id, - ledgerIndex: info.validated_ledger?.seq, - serverState: info.server_state, - }; - } catch (err) { - return { - connected: false, - error: err instanceof Error ? err.message : "Unknown error", - }; - } -} - -/** - * Make a JSON-RPC request to XRPL. - * Used for one-off requests when WebSocket isn't needed. - */ -export async function xrplRpcRequest( - method: string, - params: Record = {} -): Promise { - const response = await fetch(config.xrplRpcUrl, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - method, - params: [params], - }), - }); - - if (!response.ok) { - throw new Error(`XRPL RPC error: ${response.status} ${response.statusText}`); - } - - const data = (await response.json()) as { - result: T & { error?: string; error_message?: string }; - }; - - if (data.result?.error) { - throw new Error(`XRPL error: ${data.result.error_message || data.result.error}`); - } - - return data.result as T; -} - -/** - * Get account info via JSON-RPC. - */ -export async function getAccountInfo(address: string) { - return xrplRpcRequest<{ - account_data: { - Account: string; - Balance: string; - Sequence: number; - }; - ledger_current_index: number; - }>("account_info", { - account: address, - ledger_index: "current", - }); -} - -/** - * Get current ledger info via JSON-RPC. - */ -export async function getLedgerInfo() { - return xrplRpcRequest<{ - ledger: { - ledger_index: number; - ledger_hash: string; - close_time: number; - }; - }>("ledger", { - ledger_index: "validated", - }); -} - -/** - * Get transaction details via JSON-RPC. - * Returns the transaction with its Sequence number. - */ -export async function getTransaction(txHash: string) { - return xrplRpcRequest<{ - Account: string; - Sequence: number; - TransactionType: string; - hash: string; - validated: boolean; - meta?: { - TransactionResult: string; - }; - }>("tx", { - transaction: txHash, - binary: false, - }); -} - -/** - * Sign and submit a transaction using the issuer wallet. - * Used for server-side token minting. - */ -export async function signAndSubmitWithIssuer( - tx: SubmittableTransaction -): Promise<{ hash: string; result: string }> { - if (!config.issuerSecret) { - throw new Error("XRPL_ISSUER_SECRET not configured"); - } - - const client = await createXrplClient(); - const wallet = Wallet.fromSeed(config.issuerSecret); - - // Autofill transaction fields (Fee, Sequence, etc.) - const prepared = await client.autofill(tx); - - // Sign the transaction - const signed = wallet.sign(prepared); - - // Submit and wait for validation - const result = await client.submitAndWait(signed.tx_blob); - - const txResult = (result.result.meta as { TransactionResult?: string })?.TransactionResult; - - if (txResult !== "tesSUCCESS") { - throw new Error(`Transaction failed: ${txResult}`); - } - - return { - hash: result.result.hash, - result: txResult, - }; -} - -/** - * Sign and submit a transaction using the operator wallet. - * Used for server-side payouts. - */ -export async function signAndSubmitWithOperator( - tx: SubmittableTransaction -): Promise<{ hash: string; result: string }> { - if (!config.operatorSecret) { - throw new Error("XRPL_OPERATOR_SECRET not configured"); - } - - const client = await createXrplClient(); - const wallet = Wallet.fromSeed(config.operatorSecret); - - // Autofill transaction fields (Fee, Sequence, etc.) - const prepared = await client.autofill(tx); - - // Sign the transaction - const signed = wallet.sign(prepared); - - // Submit and wait for validation - const result = await client.submitAndWait(signed.tx_blob); - - const txResult = (result.result.meta as { TransactionResult?: string })?.TransactionResult; - - if (txResult !== "tesSUCCESS") { - throw new Error(`Transaction failed: ${txResult}`); - } - - return { - hash: result.result.hash, - result: txResult, - }; -} diff --git a/apps/api/src/xrpl/ledger-sync.ts b/apps/api/src/xrpl/ledger-sync.ts deleted file mode 100644 index 337abe3..0000000 --- a/apps/api/src/xrpl/ledger-sync.ts +++ /dev/null @@ -1,282 +0,0 @@ -/** - * XRPL ledger synchronization service. - * - * Subscribes to the XRPL ledger stream and processes transactions - * containing MITATE memos, storing events in the ledger_events table. - * - * Uses system_state for cursor persistence (survives restarts). - */ -import type { Client, TransactionStream } from "xrpl"; -import { createXrplClient, getXrplClient } from "./client"; -import { decodeMemo, isMitateMemo, type XrplMemo, type MitateMemoData } from "./memo"; -import { insertLedgerEvent, getLedgerEventByTxHash } from "../db/models/ledger-events"; -import { getSystemState, setSystemState } from "../db/models/system-state"; -import { config } from "../config"; - -// ── Types ────────────────────────────────────────────────────────── - -export interface SyncStatus { - running: boolean; - lastLedgerIndex: number | null; - lastSyncTime: string | null; - error: string | null; -} - -interface ParsedMitateTransaction { - txHash: string; - ledgerIndex: number; - memoData: MitateMemoData; - rawTx: Record; -} - -// ── Constants ────────────────────────────────────────────────────── - -const STATE_KEY_LAST_LEDGER = "sync:last_ledger_index"; -const STATE_KEY_LAST_SYNC = "sync:last_sync_time"; - -// ── State ────────────────────────────────────────────────────────── - -let syncRunning = false; -let lastError: string | null = null; - -// ── Public API ───────────────────────────────────────────────────── - -/** - * Get current sync status. - */ -export function getSyncStatus(): SyncStatus { - const lastLedger = getSystemState(STATE_KEY_LAST_LEDGER); - const lastSync = getSystemState(STATE_KEY_LAST_SYNC); - - return { - running: syncRunning, - lastLedgerIndex: lastLedger ? parseInt(lastLedger, 10) : null, - lastSyncTime: lastSync, - error: lastError, - }; -} - -/** - * Start the ledger sync service. - * Subscribes to the ledger stream and processes MITATE transactions. - */ -export async function startLedgerSync(): Promise { - if (syncRunning) { - console.warn("Ledger sync already running"); - return; - } - - try { - const client = await createXrplClient(); - syncRunning = true; - lastError = null; - - console.log("Starting ledger sync..."); - - // Subscribe to transactions on the operator account - await client.request({ - command: "subscribe", - accounts: [config.operatorAddress], - }); - - // Also subscribe to ledger stream for general monitoring - await client.request({ - command: "subscribe", - streams: ["ledger"], - }); - - // Handle incoming transaction events - client.on("transaction", (event: TransactionStream) => { - handleTransaction(event).catch((err) => { - console.error("Error handling transaction:", err); - lastError = err instanceof Error ? err.message : "Unknown error"; - }); - }); - - // Handle ledger close events (for cursor updates) - client.on("ledgerClosed", (ledger) => { - const index = ledger.ledger_index; - setSystemState(STATE_KEY_LAST_LEDGER, String(index)); - setSystemState(STATE_KEY_LAST_SYNC, new Date().toISOString()); - }); - - console.log("Ledger sync started"); - } catch (err) { - syncRunning = false; - lastError = err instanceof Error ? err.message : "Unknown error"; - console.error("Failed to start ledger sync:", err); - throw err; - } -} - -/** - * Stop the ledger sync service. - */ -export async function stopLedgerSync(): Promise { - const client = getXrplClient(); - if (!client) { - syncRunning = false; - return; - } - - try { - await client.request({ - command: "unsubscribe", - accounts: [config.operatorAddress], - }); - await client.request({ - command: "unsubscribe", - streams: ["ledger"], - }); - } catch (err) { - console.warn("Error unsubscribing:", err); - } - - syncRunning = false; - console.log("Ledger sync stopped"); -} - -/** - * Backfill events from a specific ledger range. - * Useful for catching up after downtime. - */ -export async function backfillLedgerRange( - startLedger: number, - endLedger: number -): Promise { - const client = await createXrplClient(); - let processedCount = 0; - - console.log(`Backfilling ledgers ${startLedger} to ${endLedger}...`); - - for (let ledgerIndex = startLedger; ledgerIndex <= endLedger; ledgerIndex++) { - try { - const response = await client.request({ - command: "ledger", - ledger_index: ledgerIndex, - transactions: true, - expand: true, - }); - - const txs = response.result.ledger.transactions ?? []; - for (const tx of txs) { - if (typeof tx === "object" && tx !== null) { - const parsed = parseMitateTransaction(tx as Record, ledgerIndex); - if (parsed) { - await processTransaction(parsed); - processedCount++; - } - } - } - - // Update cursor periodically - if (ledgerIndex % 100 === 0) { - setSystemState(STATE_KEY_LAST_LEDGER, String(ledgerIndex)); - console.log(`Backfill progress: ledger ${ledgerIndex}`); - } - } catch (err) { - console.error(`Error backfilling ledger ${ledgerIndex}:`, err); - } - } - - setSystemState(STATE_KEY_LAST_LEDGER, String(endLedger)); - setSystemState(STATE_KEY_LAST_SYNC, new Date().toISOString()); - - console.log(`Backfill complete: ${processedCount} MITATE events processed`); - return processedCount; -} - -/** - * Get the last synced ledger index from state. - */ -export function getLastSyncedLedger(): number | null { - const value = getSystemState(STATE_KEY_LAST_LEDGER); - return value ? parseInt(value, 10) : null; -} - -// ── Internal ─────────────────────────────────────────────────────── - -/** - * Handle an incoming transaction stream event. - */ -async function handleTransaction(event: TransactionStream): Promise { - const tx = event.transaction; - const meta = event.meta; - - // Skip failed transactions - if (typeof meta === "object" && meta !== null) { - const result = (meta as unknown as Record).TransactionResult; - if (result !== "tesSUCCESS") { - return; - } - } - - const ledgerIndex = event.ledger_index ?? 0; - const parsed = parseMitateTransaction(tx as unknown as Record, ledgerIndex); - - if (parsed) { - await processTransaction(parsed); - } -} - -/** - * Parse a transaction and extract MITATE memo data if present. - */ -function parseMitateTransaction( - tx: Record, - ledgerIndex: number -): ParsedMitateTransaction | null { - const memos = tx.Memos as XrplMemo[] | undefined; - if (!memos || !Array.isArray(memos)) { - return null; - } - - // Find the first MITATE memo - for (const memo of memos) { - if (!isMitateMemo(memo)) continue; - - const memoData = decodeMemo(memo); - if (!memoData) continue; - - const txHash = (tx.hash ?? tx.Hash ?? "") as string; - if (!txHash) continue; - - return { - txHash, - ledgerIndex, - memoData, - rawTx: tx, - }; - } - - return null; -} - -/** - * Process and store a parsed MITATE transaction. - * Idempotent: duplicate tx_hash entries are silently ignored. - */ -async function processTransaction(parsed: ParsedMitateTransaction): Promise { - // Check if already processed (defensive, DB handles dedup too) - const existing = getLedgerEventByTxHash(parsed.txHash); - if (existing) { - return; - } - - insertLedgerEvent({ - txHash: parsed.txHash, - eventType: parsed.memoData.type, - marketId: parsed.memoData.marketId, - payloadJson: JSON.stringify({ - memoData: parsed.memoData, - txType: parsed.rawTx.TransactionType, - account: parsed.rawTx.Account, - destination: parsed.rawTx.Destination, - }), - ledgerIndex: parsed.ledgerIndex, - }); - - console.log( - `Ingested MITATE event: ${parsed.memoData.type} for market ${parsed.memoData.marketId} (tx: ${parsed.txHash.slice(0, 8)}...)` - ); -} diff --git a/apps/api/src/xrpl/memo.ts b/apps/api/src/xrpl/memo.ts deleted file mode 100644 index 744064b..0000000 --- a/apps/api/src/xrpl/memo.ts +++ /dev/null @@ -1,99 +0,0 @@ -/** - * MITATE memo encoding/decoding for XRPL transactions. - * - * All MITATE transactions carry a Memo with: - * MemoType = hex("MITATE") - * MemoFormat = hex("application/json") - * MemoData = hex(JSON) - */ - -// ── Types ────────────────────────────────────────────────────────── - -export type MitateMemoType = - | "market" - | "bet" - | "mint" - | "offer" - | "resolve" - | "payout" - | "cancel" - | "escrow_pool" - | "burn"; - -export interface MitateMemoData { - v: 1; - type: MitateMemoType; - marketId: string; - outcome?: "YES" | "NO"; - outcomeId?: string; - amount?: string; - creator?: string; - timestamp: string; -} - -export interface XrplMemo { - Memo: { - MemoType: string; - MemoFormat: string; - MemoData: string; - }; -} - -// ── Constants ────────────────────────────────────────────────────── - -const MEMO_TYPE = "MITATE"; -const MEMO_FORMAT = "application/json"; - -// ── Hex helpers ──────────────────────────────────────────────────── - -export function toHex(str: string): string { - return Buffer.from(str, "utf-8").toString("hex").toUpperCase(); -} - -export function fromHex(hex: string): string { - return Buffer.from(hex, "hex").toString("utf-8"); -} - -// ── Encode / Decode ──────────────────────────────────────────────── - -/** - * Encode a MitateMemoData object into an XRPL Memo field. - */ -export function encodeMemo(data: MitateMemoData): XrplMemo { - return { - Memo: { - MemoType: toHex(MEMO_TYPE), - MemoFormat: toHex(MEMO_FORMAT), - MemoData: toHex(JSON.stringify(data)), - }, - }; -} - -/** - * Decode an XRPL Memo field back into MitateMemoData. - * Returns null if the memo is not a valid MITATE memo. - */ -export function decodeMemo(memo: XrplMemo): MitateMemoData | null { - try { - const memoType = fromHex(memo.Memo.MemoType); - if (memoType !== MEMO_TYPE) return null; - - const raw = JSON.parse(fromHex(memo.Memo.MemoData)); - if (raw.v !== 1) return null; - - return raw as MitateMemoData; - } catch { - return null; - } -} - -/** - * Check if an XRPL Memo is a MITATE memo (without full decode). - */ -export function isMitateMemo(memo: XrplMemo): boolean { - try { - return fromHex(memo.Memo.MemoType) === MEMO_TYPE; - } catch { - return false; - } -} diff --git a/apps/api/src/xrpl/tx-builder.ts b/apps/api/src/xrpl/tx-builder.ts deleted file mode 100644 index 098f07b..0000000 --- a/apps/api/src/xrpl/tx-builder.ts +++ /dev/null @@ -1,353 +0,0 @@ -/** - * XRPL transaction builders for MITATE prediction markets. - * - * Each builder returns a typed XRPL transaction object ready for - * autofill() + sign + submit. All transactions include MITATE memos. - */ -import type { - Payment, - TrustSet, - EscrowCreate, - EscrowFinish, - EscrowCancel, - OfferCreate, -} from "xrpl"; -import { encodeMemo, type MitateMemoData, type XrplMemo } from "./memo"; - -// ── Currency code encoding ───────────────────────────────────────── - -/** - * Encode a 160-bit (20-byte) non-standard XRPL currency code. - * - * Format: 0x02 prefix byte + up to 19 bytes of ":". - * The 0x02 prefix ensures the first byte is non-zero (required by XRPL - * to distinguish from 3-character standard currency codes). - */ -export function encodeCurrencyCode( - marketId: string, - outcome: "YES" | "NO" -): string { - const label = `${marketId}:${outcome}`; - const buf = Buffer.alloc(20, 0); - buf[0] = 0x02; // non-standard currency marker - const encoded = Buffer.from(label, "utf-8"); - encoded.copy(buf, 1, 0, Math.min(encoded.length, 19)); - return buf.toString("hex").toUpperCase(); -} - -/** - * Decode a 160-bit currency code back to marketId + outcome. - */ -export function decodeCurrencyCode( - hex: string -): { marketId: string; outcome: "YES" | "NO" } | null { - try { - const buf = Buffer.from(hex, "hex"); - if (buf.length !== 20 || buf[0] !== 0x02) return null; - - // Strip trailing zero bytes - let end = 20; - while (end > 1 && buf[end - 1] === 0) end--; - - const label = buf.subarray(1, end).toString("utf-8"); - const sep = label.lastIndexOf(":"); - if (sep === -1) return null; - - const marketId = label.substring(0, sep); - const outcome = label.substring(sep + 1); - if (outcome !== "YES" && outcome !== "NO") return null; - - return { marketId, outcome }; - } catch { - return null; - } -} - -// ── Internal helpers ─────────────────────────────────────────────── - -function buildMemo( - type: MitateMemoData["type"], - marketId: string, - extra?: Partial> -): XrplMemo { - return encodeMemo({ - v: 1, - type, - marketId, - timestamp: new Date().toISOString(), - ...extra, - }); -} - -// ── Escrow builders ──────────────────────────────────────────────── - -export function buildEscrowCreate(params: { - account: string; - amountDrops: string; - cancelAfter: number; // Ripple epoch seconds - finishAfter?: number; - marketId: string; - destinationTag?: number; -}): EscrowCreate { - // XRPL requires either Condition or FinishAfter - // FinishAfter must be in the future, set to 1 minute from now - const rippleEpochOffset = 946684800; - const oneMinuteFromNow = Math.floor(Date.now() / 1000) + 60 - rippleEpochOffset; - - const tx: EscrowCreate = { - TransactionType: "EscrowCreate", - Account: params.account, - Destination: params.account, // self-escrow pool - Amount: params.amountDrops, - CancelAfter: params.cancelAfter, - FinishAfter: params.finishAfter ?? oneMinuteFromNow, // Must be in future - Memos: [buildMemo("escrow_pool", params.marketId)], - }; - if (params.destinationTag !== undefined) - tx.DestinationTag = params.destinationTag; - return tx; -} - -export function buildEscrowFinish(params: { - account: string; - offerSequence: number; - marketId: string; - outcome: "YES" | "NO"; -}): EscrowFinish { - return { - TransactionType: "EscrowFinish", - Account: params.account, - Owner: params.account, - OfferSequence: params.offerSequence, - Memos: [buildMemo("resolve", params.marketId, { outcome: params.outcome })], - }; -} - -export function buildEscrowCancel(params: { - account: string; - offerSequence: number; - marketId: string; -}): EscrowCancel { - return { - TransactionType: "EscrowCancel", - Account: params.account, - Owner: params.account, - OfferSequence: params.offerSequence, - Memos: [buildMemo("cancel", params.marketId)], - }; -} - -// ── Payment builders ─────────────────────────────────────────────── - -/** User -> Operator XRP bet payment (legacy YES/NO). */ -export function buildBetPayment(params: { - account: string; - destination: string; - amountDrops: string; - marketId: string; - outcome: "YES" | "NO"; -}): Payment { - return { - TransactionType: "Payment", - Account: params.account, - Destination: params.destination, - Amount: params.amountDrops, - Memos: [ - buildMemo("bet", params.marketId, { - outcome: params.outcome, - amount: params.amountDrops, - }), - ], - }; -} - -/** User -> Operator XRP bet payment for multi-outcome markets. */ -export function buildOutcomeBetPayment(params: { - account: string; - destination: string; - amountDrops: string; - marketId: string; - outcomeId: string; -}): Payment { - return { - TransactionType: "Payment", - Account: params.account, - Destination: params.destination, - Amount: params.amountDrops, - Memos: [ - buildMemo("bet", params.marketId, { - outcomeId: params.outcomeId, - amount: params.amountDrops, - }), - ], - }; -} - -/** Issuer -> User IOU mint payment (legacy YES/NO). */ -export function buildMintPayment(params: { - issuerAddress: string; - destination: string; - marketId: string; - outcome: "YES" | "NO"; - tokenValue: string; -}): Payment { - return { - TransactionType: "Payment", - Account: params.issuerAddress, - Destination: params.destination, - Amount: { - currency: encodeCurrencyCode(params.marketId, params.outcome), - issuer: params.issuerAddress, - value: params.tokenValue, - }, - Memos: [ - buildMemo("mint", params.marketId, { - outcome: params.outcome, - amount: params.tokenValue, - }), - ], - }; -} - -/** Issuer -> User IOU mint payment for multi-outcome markets. */ -export function buildOutcomeMintPayment(params: { - issuerAddress: string; - destination: string; - marketId: string; - outcomeId: string; - currencyCode: string; - tokenValue: string; -}): Payment { - return { - TransactionType: "Payment", - Account: params.issuerAddress, - Destination: params.destination, - Amount: { - currency: params.currencyCode, - issuer: params.issuerAddress, - value: params.tokenValue, - }, - Memos: [ - buildMemo("mint", params.marketId, { - outcomeId: params.outcomeId, - amount: params.tokenValue, - }), - ], - }; -} - -/** Operator -> Winner XRP payout payment (legacy YES/NO). */ -export function buildPayoutPayment(params: { - operatorAddress: string; - destination: string; - amountDrops: string; - marketId: string; - outcome: "YES" | "NO"; -}): Payment { - return { - TransactionType: "Payment", - Account: params.operatorAddress, - Destination: params.destination, - Amount: params.amountDrops, - Memos: [ - buildMemo("payout", params.marketId, { - outcome: params.outcome, - amount: params.amountDrops, - }), - ], - }; -} - -/** Operator -> Winner XRP payout payment for multi-outcome markets. */ -export function buildOutcomePayoutPayment(params: { - operatorAddress: string; - destination: string; - amountDrops: string; - marketId: string; - outcomeId: string; -}): Payment { - return { - TransactionType: "Payment", - Account: params.operatorAddress, - Destination: params.destination, - Amount: params.amountDrops, - Memos: [ - buildMemo("payout", params.marketId, { - outcomeId: params.outcomeId, - amount: params.amountDrops, - }), - ], - }; -} - -// ── TrustSet builders ────────────────────────────────────────────── - -/** User sets trust line for outcome IOU (legacy YES/NO). */ -export function buildTrustSet(params: { - account: string; - issuerAddress: string; - marketId: string; - outcome: "YES" | "NO"; - limitValue: string; -}): TrustSet { - return { - TransactionType: "TrustSet", - Account: params.account, - LimitAmount: { - currency: encodeCurrencyCode(params.marketId, params.outcome), - issuer: params.issuerAddress, - value: params.limitValue, - }, - Memos: [buildMemo("bet", params.marketId, { outcome: params.outcome })], - }; -} - -/** User sets trust line for multi-outcome IOU. */ -export function buildOutcomeTrustSet(params: { - account: string; - issuerAddress: string; - marketId: string; - outcomeId: string; - currencyCode: string; - limitValue: string; -}): TrustSet { - return { - TransactionType: "TrustSet", - Account: params.account, - LimitAmount: { - currency: params.currencyCode, - issuer: params.issuerAddress, - value: params.limitValue, - }, - Memos: [buildMemo("bet", params.marketId, { outcomeId: params.outcomeId })], - }; -} - -// ── OfferCreate builder ──────────────────────────────────────────── - -/** User creates DEX offer to sell outcome IOUs for XRP. */ -export function buildOfferCreate(params: { - account: string; - issuerAddress: string; - marketId: string; - outcome: "YES" | "NO"; - takerGetsTokenValue: string; - takerPaysDrops: string; - expiration?: number; // Ripple epoch seconds -}): OfferCreate { - const tx: OfferCreate = { - TransactionType: "OfferCreate", - Account: params.account, - TakerGets: { - currency: encodeCurrencyCode(params.marketId, params.outcome), - issuer: params.issuerAddress, - value: params.takerGetsTokenValue, - }, - TakerPays: params.takerPaysDrops, - Memos: [ - buildMemo("offer", params.marketId, { outcome: params.outcome }), - ], - }; - if (params.expiration !== undefined) tx.Expiration = params.expiration; - return tx; -} diff --git a/apps/web/app/activity/page.tsx b/apps/web/app/activity/page.tsx index 0bdfdd5..daf3970 100644 --- a/apps/web/app/activity/page.tsx +++ b/apps/web/app/activity/page.tsx @@ -6,7 +6,7 @@ import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { useWallet } from "@/contexts/WalletContext"; import { useUser } from "@/contexts/UserContext"; -import { formatXrp } from "@/lib/api"; +import { formatEth } from "@/lib/api"; export default function ActivityPage() { const wallet = useWallet(); @@ -18,16 +18,16 @@ export default function ActivityPage() {

ウォレットを接続

- GemWalletを接続してアクティビティ履歴を確認しましょう。 + MetaMaskを接続してアクティビティ履歴を確認しましょう。

- {!wallet.gemWalletInstalled ? ( + {!wallet.metaMaskInstalled ? ( ) : ( @@ -37,7 +37,7 @@ export default function ActivityPage() { className="w-full" size="lg" > - {wallet.loading ? "接続中..." : "GemWalletを接続"} + {wallet.loading ? "接続中..." : "MetaMaskを接続"} )}
@@ -99,7 +99,7 @@ export default function ActivityPage() {
- {formatXrp(bet.amountDrops)} + {formatEth(bet.amountWei)}
{new Date(bet.createdAt).toLocaleDateString("ja-JP")} diff --git a/apps/web/app/admin/page.tsx b/apps/web/app/admin/page.tsx index 7b3ec91..ca2a987 100644 --- a/apps/web/app/admin/page.tsx +++ b/apps/web/app/admin/page.tsx @@ -5,7 +5,6 @@ import { Button } from "@/components/ui/button"; import { Card, CardContent, - CardDescription, CardHeader, CardTitle, } from "@/components/ui/card"; @@ -30,17 +29,14 @@ import { } from "@/components/ui/dialog"; import { useToast } from "@/hooks/use-toast"; import { useWallet } from "@/contexts/WalletContext"; -import type { Market, CreateMarketResponse } from "@/lib/api"; +import type { Market } from "@/lib/api"; import { adminGetMarkets, adminCreateMarket, - adminConfirmMarket, adminCloseMarket, adminResolveMarket, fetchCategories, - dropsToXrp, } from "@/lib/api"; -import { isInstalled, submitTransaction } from "@gemwallet/api"; const ADMIN_KEY_STORAGE = "mitate-admin-key"; @@ -75,7 +71,6 @@ function AdminAuth({ onAuth }: { onAuth: (key: string) => void }) { 管理画面 - 管理キーを入力してください
void }) { ); } -// ── Create Market dialog with Escrow flow ─────────────────────── - -type CreateStep = "form" | "sign" | "confirming" | "done"; +// ── Create Market dialog ──────────────────────────────────────── function CreateMarketDialog({ adminKey, @@ -115,20 +108,15 @@ function CreateMarketDialog({ onCreated: () => void; }) { const { toast } = useToast(); - const { address: walletAddress, connected: walletConnected } = useWallet(); const [open, setOpen] = useState(false); - const [step, setStep] = useState("form"); const [loading, setLoading] = useState(false); - + // Form state const [title, setTitle] = useState(""); const [description, setDescription] = useState(""); const [category, setCategory] = useState(""); const [deadline, setDeadline] = useState(""); const [outcomes, setOutcomes] = useState(["", ""]); - - // Escrow state - const [createdMarket, setCreatedMarket] = useState(null); function reset() { setTitle(""); @@ -136,8 +124,6 @@ function CreateMarketDialog({ setCategory(""); setDeadline(""); setOutcomes(["", ""]); - setCreatedMarket(null); - setStep("form"); } function handleClose() { @@ -145,8 +131,7 @@ function CreateMarketDialog({ setOpen(false); } - // Step 1: Create market in DB (Draft status) - async function handleCreateDraft() { + async function handleCreate() { const filteredOutcomes = outcomes.filter((o) => o.trim()); if (!title.trim() || !deadline || filteredOutcomes.length < 2) { toast({ title: "入力エラー", description: "必須項目を入力してください", variant: "destructive" }); @@ -156,7 +141,7 @@ function CreateMarketDialog({ setLoading(true); try { const cat = categories.find((c) => c.value === category); - const result = await adminCreateMarket(adminKey, { + await adminCreateMarket(adminKey, { title: title.trim(), description: description.trim(), category: category || undefined, @@ -164,76 +149,14 @@ function CreateMarketDialog({ bettingDeadline: new Date(deadline).toISOString(), outcomes: filteredOutcomes.map((label) => ({ label: label.trim() })), }); - - setCreatedMarket(result); - setStep("sign"); - toast({ title: "マーケット作成", description: "EscrowCreateトランザクションに署名してください" }); - } catch (err) { - toast({ - title: "エラー", - description: err instanceof Error ? err.message : "作成に失敗しました", - variant: "destructive", - }); - } finally { - setLoading(false); - } - } - - // Step 2: Sign and submit escrow with GemWallet - async function handleSignEscrow() { - if (!createdMarket?.escrowTx) { - toast({ title: "エラー", description: "Escrowトランザクションがありません", variant: "destructive" }); - return; - } - - // Check GemWallet installation - const installedResult = await isInstalled(); - if (!installedResult.result?.isInstalled) { - toast({ title: "GemWallet未インストール", description: "GemWalletをインストールしてください", variant: "destructive" }); - return; - } - - setLoading(true); - setStep("confirming"); - - try { - // Submit transaction via GemWallet (signs and submits) - const submitResult = await submitTransaction({ - transaction: createdMarket.escrowTx as Parameters[0]["transaction"], - }); - if (submitResult.type === "reject") { - throw new Error("トランザクションが拒否されました"); - } - - // Get tx hash from result - const txHash = submitResult.result?.hash; - - if (!txHash) { - throw new Error("トランザクションハッシュが取得できませんでした"); - } - - // For escrow sequence, we'll use a placeholder and let the API look it up - // The API can fetch the tx from XRPL to get the sequence - // For now, use 1 as placeholder (API should handle this) - const sequence = 1; - - // Confirm with API - await adminConfirmMarket(adminKey, createdMarket.id, txHash, sequence); - - setStep("done"); toast({ title: "成功", description: "マーケットを公開しました!" }); - - // Auto-close after success - setTimeout(() => { - handleClose(); - onCreated(); - }, 1500); + handleClose(); + onCreated(); } catch (err) { - setStep("sign"); // Go back to sign step toast({ - title: "署名エラー", - description: err instanceof Error ? err.message : "署名に失敗しました", + title: "エラー", + description: err instanceof Error ? err.message : "作成に失敗しました", variant: "destructive", }); } finally { @@ -241,269 +164,88 @@ function CreateMarketDialog({ } } - // Convert Ripple epoch to human readable - function formatRippleTime(rippleSeconds: number): string { - const rippleEpoch = 946684800; - const unixMs = (rippleSeconds + rippleEpoch) * 1000; - return new Date(unixMs).toLocaleString("ja-JP"); - } - return ( { if (!v) handleClose(); else setOpen(true); }}> - {step === "form" && ( - <> - - 新規マーケット作成 - マーケットの情報を入力してください - - -
-
- - setTitle(e.target.value)} placeholder="マーケットのタイトル" /> -
- -
- - setDescription(e.target.value)} placeholder="説明文" /> -
+ + 新規マーケット作成 + マーケットの情報を入力してください + -
- - -
+
+
+ + setTitle(e.target.value)} placeholder="マーケットのタイトル" /> +
-
- - setDeadline(e.target.value)} /> -
+
+ + setDescription(e.target.value)} placeholder="説明文" /> +
-
- - {outcomes.map((o, i) => ( -
- { - const next = [...outcomes]; - next[i] = e.target.value; - setOutcomes(next); - }} - placeholder={`選択肢 ${i + 1}`} - /> - {outcomes.length > 2 && ( - - )} -
+
+ + +
- - - - - - )} - - {step === "sign" && createdMarket && ( - <> - - EscrowCreate署名 - GemWalletでトランザクションに署名してマーケットを公開 - - -
-
-
- トランザクション種別 - EscrowCreate -
-
- 金額 - {dropsToXrp(createdMarket.escrowTx.Amount)} XRP -
-
- キャンセル可能日時 - {formatRippleTime(createdMarket.escrowTx.CancelAfter)} -
-
- 送信元 - {createdMarket.escrowTx.Account.slice(0, 8)}... -
-
+
+ + setDeadline(e.target.value)} /> +
-
-

※ EscrowCreateはマーケットのプール資金をXRPL上にロックします。

-

※ マーケット解決時にEscrowFinishで解放されます。

+
+ + {outcomes.map((o, i) => ( +
+ { + const next = [...outcomes]; + next[i] = e.target.value; + setOutcomes(next); + }} + placeholder={`選択肢 ${i + 1}`} + /> + {outcomes.length > 2 && ( + + )}
- - {!walletConnected && ( -
- GemWalletが接続されていません。接続してから署名してください。 -
- )} -
- - - - - - - )} - - {step === "confirming" && ( - <> - - 確認中... - トランザクションを処理しています - -
-
-
- - )} - - {step === "done" && ( - <> - - ✅ 公開完了 - マーケットが正常に公開されました - -
-
🎉
-
- - )} - -
- ); -} - -// ── Open Draft Market (for existing drafts) ───────────────────── - -function OpenDraftDialog({ - market, - adminKey, - onOpened, -}: { - market: Market; - adminKey: string; - onOpened: () => void; -}) { - const { toast } = useToast(); - const [open, setOpen] = useState(false); - const [loading, setLoading] = useState(false); - - async function handleOpen() { - setLoading(true); - try { - // For existing drafts without escrow, we need to create and sign - const installedResult = await isInstalled(); - if (!installedResult.result?.isInstalled) { - toast({ title: "GemWallet未インストール", description: "GemWalletをインストールしてください", variant: "destructive" }); - setLoading(false); - return; - } - - // Build escrow tx manually for existing draft - const rippleEpochOffset = 946684800; - const deadline = new Date(market.bettingDeadline); - const cancelAfter = Math.floor(deadline.getTime() / 1000) - rippleEpochOffset; - - const escrowTx = { - TransactionType: "EscrowCreate" as const, - Account: market.issuerAddress || "", - Destination: market.issuerAddress || "", - Amount: "1", - CancelAfter: cancelAfter, - }; - - const submitResult = await submitTransaction({ - transaction: escrowTx as Parameters[0]["transaction"], - }); - if (submitResult.type === "reject") { - throw new Error("署名が拒否されました"); - } - - const txHash = submitResult.result?.hash; - - if (!txHash) { - throw new Error("トランザクションハッシュが取得できません"); - } - - await adminConfirmMarket(adminKey, market.id, txHash, 1); - toast({ title: "成功", description: "マーケットを公開しました" }); - setOpen(false); - onOpened(); - } catch (err) { - toast({ - title: "エラー", - description: err instanceof Error ? err.message : "公開に失敗しました", - variant: "destructive", - }); - } finally { - setLoading(false); - } - } - - return ( - - - - - - - マーケットを公開 - {market.title} - -
-

EscrowCreateトランザクションに署名してマーケットを公開します。

-

GemWalletで署名が必要です。

+ )} +
+ - - @@ -621,9 +363,6 @@ function MarketActions({ return (
- {status === "Draft" && ( - - )} {status === "Open" && ( ) : ( @@ -788,26 +512,6 @@ export default function AdminPage() {
- {/* Info card about XRPL features */} - - - XRPL機能 - - -
- Escrow - Issued Currency - Trust Line - DEX - Multi-Sign - Memo -
-

- マーケット作成時にEscrowCreate、ベット時にTrustSet+Payment、解決時にEscrowFinishを使用 -

-
-
-
@@ -821,10 +525,6 @@ export default function AdminPage() { - - {/* Token minting happens automatically when XRPL_ISSUER_SECRET is set */}
); } - -// Token minting is handled automatically server-side when XRPL_ISSUER_SECRET is configured diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 12e0b85..f071418 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -49,7 +49,7 @@ export default function RootLayout({
MITATE - © 2026 MITATE. XRPL Parimutuel Prediction Market. + © 2026 MITATE. EVM Parimutuel Prediction Market.
diff --git a/apps/web/app/market/[id]/page.tsx b/apps/web/app/market/[id]/page.tsx index 8166e31..16d7927 100644 --- a/apps/web/app/market/[id]/page.tsx +++ b/apps/web/app/market/[id]/page.tsx @@ -10,7 +10,7 @@ import { MarketInfoBox } from "@/components/market-info-box" import { PositionBox } from "@/components/position-box" import { getBetsForMarket, - formatXrp, + formatEth, type Bet, } from "@/lib/api" @@ -48,9 +48,9 @@ export default function MarketDetailPage({ params }: PageProps) { .filter((bet) => bet.bettorAddress === wallet.address) .map((bet) => ({ outcomeLabel: bet.outcomeLabel, - amountDrops: bet.amountDrops, + amountWei: bet.amountWei, weightScore: bet.weightScore, - effectiveAmountDrops: bet.effectiveAmountDrops, + effectiveAmountWei: bet.effectiveAmountWei, })) const selectedOutcome = market?.outcomes.find((o) => o.id === selectedOutcomeId) ?? null @@ -162,7 +162,7 @@ export default function MarketDetailPage({ params }: PageProps) { - {formatXrp(bet.amountDrops)} + {formatEth(bet.amountWei)} ))} @@ -193,7 +193,7 @@ export default function MarketDetailPage({ params }: PageProps) { )} sum + BigInt(b.amountDrops), BigInt(0)) + const totalBetWei = bets + .reduce((sum, b) => sum + BigInt(b.amountWei), BigInt(0)) .toString() - const totalEffectiveDrops = bets + const totalEffectiveWei = bets .reduce( - (sum, b) => sum + BigInt(b.effectiveAmountDrops || b.amountDrops), + (sum, b) => sum + BigInt(b.effectiveAmountWei || b.amountWei), BigInt(0), ) .toString() @@ -54,8 +54,8 @@ export default function MyPage() { balance={wallet.balance} weightScore={user.weightScore} betCount={bets.length} - totalBetDrops={totalBetDrops} - totalEffectiveDrops={totalEffectiveDrops} + totalBetWei={totalBetWei} + totalEffectiveWei={totalEffectiveWei} onDisconnect={wallet.disconnect} /> diff --git a/apps/web/components/active-bets.tsx b/apps/web/components/active-bets.tsx index b2e5bb0..d61f925 100644 --- a/apps/web/components/active-bets.tsx +++ b/apps/web/components/active-bets.tsx @@ -2,7 +2,7 @@ import Link from "next/link" import { cn } from "@/lib/utils" -import { formatXrp, type UserBet } from "@/lib/api" +import { formatEth, type UserBet } from "@/lib/api" export function ActiveBets({ bets }: { bets: UserBet[] }) { if (bets.length === 0) { @@ -60,7 +60,7 @@ export function ActiveBets({ bets }: { bets: UserBet[] }) {
ベット額: - {formatXrp(bet.amountDrops)} + {formatEth(bet.amountWei)}
{bet.weightScore > 1 && ( @@ -71,11 +71,11 @@ export function ActiveBets({ bets }: { bets: UserBet[] }) { )} - {bet.effectiveAmountDrops && bet.effectiveAmountDrops !== bet.amountDrops && ( + {bet.effectiveAmountWei && bet.effectiveAmountWei !== bet.amountWei && (
実効額: - {formatXrp(bet.effectiveAmountDrops)} + {formatEth(bet.effectiveAmountWei)}
)} diff --git a/apps/web/components/bet-panel.tsx b/apps/web/components/bet-panel.tsx index d3e9d18..53079a1 100644 --- a/apps/web/components/bet-panel.tsx +++ b/apps/web/components/bet-panel.tsx @@ -8,9 +8,9 @@ import { type Outcome, placeBet, confirmBet, - formatXrp, - xrpToDrops, - dropsToXrp, + formatEth, + ethToWei, + weiToEth, } from "@/lib/api" type BetPanelProps = { @@ -19,7 +19,8 @@ type BetPanelProps = { onBetPlaced: () => void } -const quickAmounts = [1, 5, 10, 50] +// Quick-select amounts in ETH +const quickAmounts = [0.001, 0.01, 0.1, 1] export function BetPanel({ marketId, @@ -35,15 +36,15 @@ export function BetPanel({ const [betConfirmed, setBetConfirmed] = useState(false) const [error, setError] = useState(null) - const balance = wallet.balance ? dropsToXrp(wallet.balance) : null + const balance = wallet.balance ? weiToEth(wallet.balance) : null const weightScore = user.weightScore const effectiveAmount = useMemo( - () => Math.round(amount * weightScore * 100) / 100, + () => Math.round(amount * weightScore * 1e6) / 1e6, [amount, weightScore] ) - // Only check balance if we have it loaded - don't block on loading balance + // Only check balance if we have it loaded const insufficientBalance = balance !== null && amount > balance const isDisabled = !selectedOutcome || amount <= 0 || !wallet.connected const isNotConnected = !wallet.connected @@ -89,41 +90,23 @@ export function BetPanel({ setError(null) try { - const amountDrops = xrpToDrops(amount) + const amountWei = ethToWei(amount) const result = await placeBet( marketId, selectedOutcome.id, - amountDrops, + amountWei, wallet.address, ) - if (result.unsignedTx) { - // Step 1: Submit TrustSet first (required for receiving minted tokens) - if (result.unsignedTx.trustSet) { - console.log("[BetPanel] Submitting TrustSet...") - const trustSetResult = await wallet.signAndSubmitTransaction(result.unsignedTx.trustSet) - if (!trustSetResult?.hash) { - throw new Error("TrustSetトランザクションが拒否されました") - } - console.log("[BetPanel] TrustSet confirmed:", trustSetResult.hash) - } - - // Step 2: Submit Payment transaction - const paymentTx = result.unsignedTx.payment - if (!paymentTx) { - throw new Error("Payment transaction not found") - } - console.log("[BetPanel] Submitting Payment...") - const txResult = await wallet.signAndSubmitTransaction(paymentTx) - if (!txResult?.hash) { - throw new Error("Paymentトランザクションが拒否されました") - } - console.log("[BetPanel] Payment confirmed:", txResult.hash) - - // Step 3: Confirm bet (triggers auto-mint on server) - await confirmBet(marketId, result.bet.id, txResult.hash) + // Submit EVM payment transaction via MetaMask + const txResult = await wallet.signAndSubmitTransaction(result.unsignedTx) + if (!txResult?.hash) { + throw new Error("トランザクションが拒否されました") } + // Confirm bet on server + await confirmBet(marketId, result.bet.id, txResult.hash) + setBetConfirmed(true) setAmount(0) setInputValue("") @@ -159,13 +142,13 @@ export function BetPanel({ htmlFor="bet-amount" className="block text-xs font-medium text-foreground" > - ベット金額 (XRP) + ベット金額 (ETH) handleAmountChange(e.target.value)} placeholder="0" @@ -186,7 +169,7 @@ export function BetPanel({ : "border-border text-foreground hover:border-foreground disabled:cursor-not-allowed disabled:opacity-40" )} > - {q} XRP + {q} ETH ))} @@ -195,7 +178,7 @@ export function BetPanel({

利用可能:{" "} - {wallet.balance ? formatXrp(wallet.balance) : "読み込み中..."} + {wallet.balance ? formatEth(wallet.balance) : "読み込み中..."} {insufficientBalance && ( (残高不足) @@ -240,7 +223,7 @@ export function BetPanel({

ベット金額 - {amount} XRP + {amount} ETH
@@ -255,7 +238,7 @@ export function BetPanel({ 実効ベット額 - {effectiveAmount} XRP + {effectiveAmount} ETH
diff --git a/apps/web/components/header.tsx b/apps/web/components/header.tsx index fe9485f..8fe0574 100644 --- a/apps/web/components/header.tsx +++ b/apps/web/components/header.tsx @@ -10,7 +10,7 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { useWallet } from "@/contexts/WalletContext"; -import { formatXrp } from "@/lib/api"; +import { formatEth } from "@/lib/api"; export function Header() { const wallet = useWallet(); @@ -68,28 +68,19 @@ export function Header() { {formatAddress(wallet.address || "")} {wallet.balance && ( - {formatXrp(wallet.balance)} + {formatEth(wallet.balance)} )} - GemWallet • {wallet.network} + MetaMask • Chain {wallet.network} マイページ - - - XRPL Explorerで見る → - - 接続中... - ) : !wallet.gemWalletInstalled ? ( + ) : !wallet.metaMaskInstalled ? ( - GemWalletをインストール + MetaMaskをインストール ) : ( "ウォレット接続" diff --git a/apps/web/components/market-card.tsx b/apps/web/components/market-card.tsx index 0bf72f2..08157d2 100644 --- a/apps/web/components/market-card.tsx +++ b/apps/web/components/market-card.tsx @@ -1,6 +1,6 @@ import Link from "next/link" import { cn } from "@/lib/utils" -import { type Market, formatXrp, formatDeadline } from "@/lib/api" +import { type Market, formatEth, formatDeadline } from "@/lib/api" function ProbabilityBar({ label, probability }: { label: string; probability: number }) { return ( @@ -71,7 +71,7 @@ export function MarketCard({ market }: { market: Market }) {
- 総取引量: {formatXrp(market.totalPoolDrops)} + 総取引量: {formatEth(market.totalPoolWei)} {formatDeadline(market.bettingDeadline)} diff --git a/apps/web/components/market-info-box.tsx b/apps/web/components/market-info-box.tsx index 087869f..39652ee 100644 --- a/apps/web/components/market-info-box.tsx +++ b/apps/web/components/market-info-box.tsx @@ -1,14 +1,14 @@ -import { formatXrp } from "@/lib/api" +import { formatEth } from "@/lib/api" type MarketInfoBoxProps = { - totalPoolDrops: string + totalPoolWei: string participants: number createdAt: string endDate: string } export function MarketInfoBox({ - totalPoolDrops, + totalPoolWei, participants, createdAt, endDate, @@ -22,7 +22,7 @@ export function MarketInfoBox({ } const rows = [ - { label: "総取引量", value: formatXrp(totalPoolDrops) }, + { label: "総取引量", value: formatEth(totalPoolWei) }, { label: "参加者数", value: `${participants}人` }, { label: "作成日", value: formatDate(createdAt) }, { label: "終了日", value: formatDate(endDate) }, diff --git a/apps/web/components/outcomes-list.tsx b/apps/web/components/outcomes-list.tsx index 26a9c72..37faac4 100644 --- a/apps/web/components/outcomes-list.tsx +++ b/apps/web/components/outcomes-list.tsx @@ -1,7 +1,7 @@ "use client" import { cn } from "@/lib/utils" -import { type Outcome, formatXrp } from "@/lib/api" +import { type Outcome, formatEth } from "@/lib/api" type OutcomesListProps = { outcomes: Outcome[] @@ -54,7 +54,7 @@ export function OutcomesList({
- {formatXrp(outcome.totalAmountDrops)} + {formatEth(outcome.totalAmountWei)}
diff --git a/apps/web/components/position-box.tsx b/apps/web/components/position-box.tsx index d573b88..b780cb9 100644 --- a/apps/web/components/position-box.tsx +++ b/apps/web/components/position-box.tsx @@ -1,10 +1,10 @@ -import { formatXrp } from "@/lib/api" +import { formatEth } from "@/lib/api" type Position = { outcomeLabel: string - amountDrops: string + amountWei: string weightScore: number - effectiveAmountDrops: string + effectiveAmountWei: string } type PositionBoxProps = { @@ -31,7 +31,7 @@ export function PositionBox({ positions }: PositionBoxProps) {
ベット額 - {formatXrp(pos.amountDrops)} + {formatEth(pos.amountWei)}
@@ -43,7 +43,7 @@ export function PositionBox({ positions }: PositionBoxProps) {
実効ベット額 - {formatXrp(pos.effectiveAmountDrops)} + {formatEth(pos.effectiveAmountWei)}
diff --git a/apps/web/components/profile-section.tsx b/apps/web/components/profile-section.tsx index f7a525d..ecd338c 100644 --- a/apps/web/components/profile-section.tsx +++ b/apps/web/components/profile-section.tsx @@ -1,15 +1,15 @@ "use client" import { cn } from "@/lib/utils" -import { formatXrp } from "@/lib/api" +import { formatEth } from "@/lib/api" type ProfileSectionProps = { walletAddress: string balance: string | null weightScore: number betCount: number - totalBetDrops: string - totalEffectiveDrops: string + totalBetWei: string + totalEffectiveWei: string onDisconnect: () => void } @@ -18,8 +18,8 @@ export function ProfileSection({ balance, weightScore, betCount, - totalBetDrops, - totalEffectiveDrops, + totalBetWei, + totalEffectiveWei, onDisconnect, }: ProfileSectionProps) { return ( @@ -43,7 +43,7 @@ export function ProfileSection({

残高

- {balance ? formatXrp(balance) : "—"} + {balance ? formatEth(balance) : "—"}

@@ -61,14 +61,14 @@ export function ProfileSection({

総賭け金

- {formatXrp(totalBetDrops)} + {formatEth(totalBetWei)}

- {totalEffectiveDrops !== totalBetDrops && ( + {totalEffectiveWei !== totalBetWei && (

- 実効総額: {formatXrp(totalEffectiveDrops)} + 実効総額: {formatEth(totalEffectiveWei)}

)} diff --git a/apps/web/components/site-header.tsx b/apps/web/components/site-header.tsx index 7281181..73e2d31 100644 --- a/apps/web/components/site-header.tsx +++ b/apps/web/components/site-header.tsx @@ -4,7 +4,7 @@ import Link from "next/link" import { usePathname } from "next/navigation" import { cn } from "@/lib/utils" import { useWallet } from "@/contexts/WalletContext" -import { formatXrp } from "@/lib/api" +import { formatEth } from "@/lib/api" const navItems = [ { label: "マーケット", href: "/" }, @@ -48,7 +48,7 @@ export function SiteHeader() { {wallet.connected ? ( <> - {wallet.balance ? formatXrp(wallet.balance) : "..."} + {wallet.balance ? formatEth(wallet.balance) : "..."} )} diff --git a/apps/web/contexts/WalletContext.tsx b/apps/web/contexts/WalletContext.tsx index fe013df..8cd8802 100644 --- a/apps/web/contexts/WalletContext.tsx +++ b/apps/web/contexts/WalletContext.tsx @@ -1,12 +1,6 @@ "use client"; import React, { createContext, useContext, useState, useCallback, useEffect } from "react"; -import { - isInstalled, - getAddress, - getNetwork, - submitTransaction, -} from "@gemwallet/api"; // ── Types ────────────────────────────────────────────────────────── @@ -14,10 +8,10 @@ export interface WalletState { connected: boolean; address: string | null; network: string | null; - balance: string | null; // XRP balance in drops + balance: string | null; // ETH balance in wei loading: boolean; error: string | null; - gemWalletInstalled: boolean; + metaMaskInstalled: boolean; } export interface WalletContextType extends WalletState { @@ -31,6 +25,18 @@ export interface WalletContextType extends WalletState { const WalletContext = createContext(null); +// ── EIP-1193 window.ethereum type ───────────────────────────────── + +declare global { + interface Window { + ethereum?: { + request: (args: { method: string; params?: unknown[] }) => Promise; + on: (event: string, handler: (...args: unknown[]) => void) => void; + removeListener: (event: string, handler: (...args: unknown[]) => void) => void; + }; + } +} + // ── Provider ─────────────────────────────────────────────────────── const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001"; @@ -43,7 +49,7 @@ export function WalletProvider({ children }: { children: React.ReactNode }) { balance: null, loading: false, error: null, - gemWalletInstalled: false, + metaMaskInstalled: false, }); const refreshBalance = useCallback(async () => { @@ -51,56 +57,40 @@ export function WalletProvider({ children }: { children: React.ReactNode }) { if (!address) return; try { - // Use API proxy to avoid CORS issues const response = await fetch(`${API_URL}/balance/${address}`); const data = await response.json() as { balance?: string; error?: string }; if (data.balance) { - const balance = data.balance; - setState((s) => ({ ...s, balance })); - } else { - console.error("Balance fetch error:", data.error); + setState((s) => ({ ...s, balance: data.balance! })); } } catch (err) { console.error("Failed to fetch balance:", err); } }, [state.address]); - // Check if GemWallet is installed on mount + // Check if MetaMask is installed on mount useEffect(() => { - const checkGemWallet = async () => { - try { - const response = await isInstalled(); - setState((s) => ({ - ...s, - gemWalletInstalled: response.result.isInstalled, - })); - - // Try to restore session - const saved = localStorage.getItem("mitate_wallet"); - if (saved && response.result.isInstalled) { + const isInstalled = typeof window !== "undefined" && !!window.ethereum; + setState((s) => ({ ...s, metaMaskInstalled: isInstalled })); + + if (isInstalled) { + // Try to restore session + const saved = localStorage.getItem("mitate_wallet"); + if (saved) { + try { const parsed = JSON.parse(saved); if (parsed.address) { - // Verify the address is still valid with GemWallet - const addrResponse = await getAddress(); - if (addrResponse.result?.address === parsed.address) { - const netResponse = await getNetwork(); - setState((s) => ({ - ...s, - connected: true, - address: parsed.address, - network: netResponse.result?.network || "Testnet", - })); - } else { - localStorage.removeItem("mitate_wallet"); - } + setState((s) => ({ + ...s, + connected: true, + address: parsed.address, + network: parsed.network || null, + })); } + } catch { + localStorage.removeItem("mitate_wallet"); } - } catch (err) { - console.log("GemWallet not detected"); } - }; - - checkGemWallet(); + } }, []); // Fetch balance when address changes @@ -114,32 +104,23 @@ export function WalletProvider({ children }: { children: React.ReactNode }) { setState((s) => ({ ...s, loading: true, error: null })); try { - // Check if installed - const installedResponse = await isInstalled(); - if (!installedResponse.result.isInstalled) { - throw new Error("GemWallet is not installed. Please install it from gemwallet.app"); - } - - // Get address - const addressResponse = await getAddress(); - if (!addressResponse.result?.address) { - throw new Error("Failed to get address from GemWallet"); + if (!window.ethereum) { + throw new Error("MetaMask is not installed. Please install it from metamask.io"); } - const address = addressResponse.result.address; - - // Get network - const networkResponse = await getNetwork(); - const network = networkResponse.result?.network || "Testnet"; + const accounts = await window.ethereum.request({ + method: "eth_requestAccounts", + }) as string[]; - // Verify we're on testnet - const networkStr = String(network).toLowerCase(); - if (!networkStr.includes("testnet") && !networkStr.includes("test")) { - throw new Error(`Please switch GemWallet to Testnet. Current: ${network}`); + if (!accounts || accounts.length === 0) { + throw new Error("No accounts returned from MetaMask"); } - // Save to localStorage - localStorage.setItem("mitate_wallet", JSON.stringify({ address })); + const address = accounts[0]; + const chainIdHex = await window.ethereum.request({ method: "eth_chainId" }) as string; + const network = parseInt(chainIdHex, 16).toString(); + + localStorage.setItem("mitate_wallet", JSON.stringify({ address, network })); setState({ connected: true, @@ -148,7 +129,7 @@ export function WalletProvider({ children }: { children: React.ReactNode }) { balance: null, loading: false, error: null, - gemWalletInstalled: true, + metaMaskInstalled: true, }); } catch (err) { setState((s) => ({ @@ -177,18 +158,23 @@ export function WalletProvider({ children }: { children: React.ReactNode }) { throw new Error("Wallet not connected"); } + if (!window.ethereum) { + throw new Error("MetaMask is not available"); + } + try { - const response = await submitTransaction({ - transaction: tx as any, - }); - - if (response.result?.hash) { - // Refresh balance after transaction - setTimeout(() => refreshBalance(), 3000); - return { hash: response.result.hash }; + const hash = await window.ethereum.request({ + method: "eth_sendTransaction", + params: [tx], + }) as string; + + if (!hash) { + throw new Error("Transaction rejected"); } - throw new Error("Transaction failed or was rejected"); + // Refresh balance after transaction + setTimeout(() => refreshBalance(), 3000); + return { hash }; } catch (err) { console.error("Transaction error:", err); throw err; diff --git a/apps/web/lib/api.ts b/apps/web/lib/api.ts index 382af95..f73eb19 100644 --- a/apps/web/lib/api.ts +++ b/apps/web/lib/api.ts @@ -10,7 +10,7 @@ export interface Outcome { id: string; label: string; probability: number; - totalAmountDrops: string; + totalAmountWei: string; } export interface Market { @@ -22,12 +22,9 @@ export interface Market { status: "Draft" | "Open" | "Closed" | "Resolved" | "Paid" | "Canceled" | "Stalled"; bettingDeadline: string; resolutionTime: string | null; - totalPoolDrops: string; + totalPoolWei: string; outcomes: Outcome[]; resolvedOutcomeId: string | null; - escrowTxHash: string | null; - escrowSequence: number | null; - issuerAddress?: string; createdAt: string; } @@ -37,9 +34,9 @@ export interface Bet { outcomeId: string; outcomeLabel: string; bettorAddress: string; - amountDrops: string; + amountWei: string; weightScore: number; - effectiveAmountDrops: string; + effectiveAmountWei: string; txHash: string | null; createdAt: string; } @@ -68,7 +65,7 @@ export interface Payout { id: string; marketId: string; marketTitle?: string; - amountDrops: string; + amountWei: string; status: "Pending" | "Sent" | "Failed"; payoutTx: string | null; createdAt: string; @@ -80,7 +77,7 @@ export interface Trade { takerGets: string; takerPays: string; executedAt: string; - ledgerIndex: number; + blockNumber: number; } export interface ApiError { @@ -143,22 +140,23 @@ export async function getMarket(id: string): Promise { export interface PlaceBetResponse { bet: Bet; weightScore: number; - effectiveAmountDrops: string; + effectiveAmountWei: string; unsignedTx: { - trustSet?: unknown; - payment: unknown; + from: string; + to: string; + value: string; }; } export async function placeBet( marketId: string, outcomeId: string, - amountDrops: string, + amountWei: string, bettorAddress: string, ): Promise { return apiFetch(`/markets/${marketId}/bets`, { method: "POST", - body: JSON.stringify({ outcomeId, amountDrops, bettorAddress }), + body: JSON.stringify({ outcomeId, amountWei, bettorAddress }), }); } @@ -192,12 +190,12 @@ export interface BetPreview { export async function previewBet( marketId: string, outcomeId: string, - amountDrops: string, + amountWei: string, bettorAddress?: string, ): Promise { const params = new URLSearchParams({ outcomeId, - amountDrops, + amountWei, }); if (bettorAddress) params.set("bettorAddress", bettorAddress); @@ -238,7 +236,7 @@ export async function removeUserAttribute( export async function fetchUserBets( address: string, status?: string, -): Promise<{ bets: UserBet[]; totalBets: number; totalAmountDrops: string }> { +): Promise<{ bets: UserBet[]; totalBets: number; totalAmountWei: string }> { const query = status ? `?status=${status}` : ""; return apiFetch(`/users/${address}/bets${query}`); } @@ -255,7 +253,7 @@ export interface TradesResponse { trades: Trade[]; stats: { tradeCount: number; - volumeDrops: string; + volumeWei: string; }; } @@ -274,8 +272,8 @@ export async function getPayoutsForMarket(marketId: string): Promise<{ pending: number; sent: number; failed: number; - totalDrops: string; - sentDrops: string; + totalWei: string; + sentWei: string; }; }> { return apiFetch(`/markets/${marketId}/payouts`); @@ -298,18 +296,6 @@ export async function adminGetMarkets(adminKey: string): Promise { return Array.isArray(result) ? result : result.markets; } -export interface CreateMarketResponse extends Market { - escrowTx: { - TransactionType: "EscrowCreate"; - Account: string; - Destination: string; - Amount: string; - CancelAfter: number; - DestinationTag?: number; - Memos?: unknown[]; - }; -} - export async function adminCreateMarket( adminKey: string, body: { @@ -320,37 +306,14 @@ export async function adminCreateMarket( bettingDeadline: string; outcomes: { label: string }[]; }, -): Promise { - return apiFetch("/markets", { +): Promise { + return apiFetch("/markets", { method: "POST", headers: adminHeaders(adminKey), body: JSON.stringify(body), }); } -export async function adminConfirmMarket( - adminKey: string, - marketId: string, - escrowTxHash: string, - escrowSequence: number, -): Promise<{ id: string; status: string }> { - return apiFetch(`/markets/${marketId}/confirm`, { - method: "POST", - headers: adminHeaders(adminKey), - body: JSON.stringify({ escrowTxHash, escrowSequence }), - }); -} - -export async function adminTestOpen( - adminKey: string, - marketId: string, -): Promise<{ id: string; status: string; message: string }> { - return apiFetch(`/markets/${marketId}/test-open`, { - method: "POST", - headers: adminHeaders(adminKey), - }); -} - export async function adminCloseMarket( adminKey: string, marketId: string, @@ -375,23 +338,27 @@ export async function adminResolveMarket( // ── Formatting Helpers ───────────────────────────────────────────── -export function dropsToXrp(drops: string | number): number { - return Number(drops) / 1_000_000; +export function weiToEth(wei: string | number): number { + return Number(wei) / 1e18; } -export function xrpToDrops(xrp: number): string { - return Math.floor(xrp * 1_000_000).toString(); +export function ethToWei(eth: number): string { + return Math.floor(eth * 1e18).toString(); } -export function formatXrp(drops: string | number): string { - const xrp = dropsToXrp(drops); - return `${xrp.toLocaleString("ja-JP")} XRP`; +export function formatEth(wei: string | number): string { + const eth = weiToEth(wei); + if (eth === 0) return "0 ETH"; + if (eth < 0.0001) return `${eth.toExponential(2)} ETH`; + return `${eth.toLocaleString("ja-JP", { maximumFractionDigits: 6 })} ETH`; } -export function formatXrpCompact(drops: string | number): string { - const xrp = dropsToXrp(drops); - if (xrp >= 1000) return `${(xrp / 1000).toFixed(1)}K XRP`; - return `${xrp.toFixed(0)} XRP`; +export function formatEthCompact(wei: string | number): string { + const eth = weiToEth(wei); + if (eth >= 1000) return `${(eth / 1000).toFixed(1)}K ETH`; + if (eth >= 1) return `${eth.toFixed(4)} ETH`; + if (eth >= 0.001) return `${eth.toFixed(6)} ETH`; + return `${eth.toExponential(2)} ETH`; } export function formatDeadline(deadline: string): string { diff --git a/apps/web/lib/utils.ts b/apps/web/lib/utils.ts index f824d0b..bd0c391 100644 --- a/apps/web/lib/utils.ts +++ b/apps/web/lib/utils.ts @@ -4,24 +4,3 @@ import { twMerge } from "tailwind-merge" export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } - -// ── Currency Formatting (XRP) ────────────────────────────────────── - -export function formatXrp(drops: string | number): string { - const xrp = Number(drops) / 1_000_000; - return `${xrp.toLocaleString("ja-JP")} XRP`; -} - -export function formatXrpCompact(drops: string | number): string { - const xrp = Number(drops) / 1_000_000; - if (xrp >= 1000) return `${(xrp / 1000).toFixed(1)}K XRP`; - return `${xrp.toFixed(0)} XRP`; -} - -export function xrpToDrops(xrp: number): string { - return String(Math.floor(xrp * 1_000_000)); -} - -export function dropsToXrp(drops: string | number): number { - return Number(drops) / 1_000_000; -} diff --git a/apps/web/package.json b/apps/web/package.json index 76df030..60618e4 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -9,7 +9,6 @@ "lint": "eslint" }, "dependencies": { - "@gemwallet/api": "^3.8.0", "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-avatar": "^1.1.11", diff --git a/bun.lock b/bun.lock index 2cf7c25..8a92128 100644 --- a/bun.lock +++ b/bun.lock @@ -11,7 +11,7 @@ "dependencies": { "@hono/zod-validator": "^0.7.6", "hono": "^4.7.0", - "xrpl": "^4.2.0", + "viem": "^2.21.0", "zod": "^4.3.6", }, "devDependencies": { @@ -87,7 +87,6 @@ "name": "@mitate/web", "version": "0.1.0", "dependencies": { - "@gemwallet/api": "^3.8.0", "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-avatar": "^1.1.11", @@ -125,6 +124,8 @@ }, }, "packages": { + "@adraffy/ens-normalize": ["@adraffy/ens-normalize@1.11.1", "", {}, "sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ=="], + "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], @@ -193,8 +194,6 @@ "@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="], - "@gemwallet/api": ["@gemwallet/api@3.8.0", "", {}, "sha512-hZ6XC0mVm3Q54cgonrzk6tHS/wUMjtPHyqsqbtlnNGPouCR7OIfEDo5Y802qLZ5ah6PskhsK0DouVnwUykEM8Q=="], - "@hono/zod-validator": ["@hono/zod-validator@0.7.6", "", { "peerDependencies": { "hono": ">=3.9.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Io1B6d011Gj1KknV4rXYz4le5+5EubcWEU/speUjuw9XMMIaP3n78yXLhjd2A3PXaXaUwEAluOiAyLqhBEJgsw=="], "@hookform/resolvers": ["@hookform/resolvers@3.10.0", "", { "peerDependencies": { "react-hook-form": "^7.0.0" } }, "sha512-79Dv+3mDF7i+2ajj7SkypSKHhl1cbln1OGavqrsF7p6mbUv11xpqpacPsGDCTRvCSjEEIez2ef1NveSVL3b0Ag=="], @@ -293,7 +292,9 @@ "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.1.6", "", { "os": "win32", "cpu": "x64" }, "sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A=="], - "@noble/curves": ["@noble/curves@1.9.7", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw=="], + "@noble/ciphers": ["@noble/ciphers@1.3.0", "", {}, "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw=="], + + "@noble/curves": ["@noble/curves@1.9.1", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA=="], "@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], @@ -557,9 +558,7 @@ "@unrs/resolver-binding-win32-x64-msvc": ["@unrs/resolver-binding-win32-x64-msvc@1.11.1", "", { "os": "win32", "cpu": "x64" }, "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g=="], - "@xrplf/isomorphic": ["@xrplf/isomorphic@1.0.1", "", { "dependencies": { "@noble/hashes": "^1.0.0", "eventemitter3": "5.0.1", "ws": "^8.13.0" } }, "sha512-0bIpgx8PDjYdrLFeC3csF305QQ1L7sxaWnL5y71mCvhenZzJgku9QsA+9QCXBC1eNYtxWO/xR91zrXJy2T/ixg=="], - - "@xrplf/secret-numbers": ["@xrplf/secret-numbers@2.0.0", "", { "dependencies": { "@xrplf/isomorphic": "^1.0.1", "ripple-keypairs": "^2.0.0" } }, "sha512-z3AOibRTE9E8MbjgzxqMpG1RNaBhQ1jnfhNCa1cGf2reZUJzPMYs4TggQTc7j8+0WyV3cr7y/U8Oz99SXIkN5Q=="], + "abitype": ["abitype@1.2.3", "", { "peerDependencies": { "typescript": ">=5.0.4", "zod": "^3.22.0 || ^4.0.0" }, "optionalPeers": ["typescript", "zod"] }, "sha512-Ofer5QUnuUdTFsBRwARMoWKOH1ND5ehwYhJ3OJ/BQO+StkwQjHw0XyVh4vDttzHB7QOFhPHa/o413PJ82gU/Tg=="], "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], @@ -613,8 +612,6 @@ "baseline-browser-mapping": ["baseline-browser-mapping@2.9.19", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg=="], - "bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="], - "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], "brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], @@ -785,7 +782,7 @@ "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], - "eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="], + "eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="], "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], @@ -937,6 +934,8 @@ "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "isows": ["isows@1.0.7", "", { "peerDependencies": { "ws": "*" } }, "sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg=="], + "iterator.prototype": ["iterator.prototype@1.1.5", "", { "dependencies": { "define-data-property": "^1.1.4", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.6", "get-proto": "^1.0.0", "has-symbols": "^1.1.0", "set-function-name": "^2.0.2" } }, "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g=="], "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], @@ -1059,6 +1058,8 @@ "own-keys": ["own-keys@1.0.1", "", { "dependencies": { "get-intrinsic": "^1.2.6", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg=="], + "ox": ["ox@0.14.0", "", { "dependencies": { "@adraffy/ens-normalize": "^1.11.0", "@noble/ciphers": "^1.3.0", "@noble/curves": "1.9.1", "@noble/hashes": "^1.8.0", "@scure/bip32": "^1.7.0", "@scure/bip39": "^1.6.0", "abitype": "^1.2.3", "eventemitter3": "5.0.1" }, "peerDependencies": { "typescript": ">=5.4.0" }, "optionalPeers": ["typescript"] }, "sha512-WLOB7IKnmI3Ol6RAqY7CJdZKl8QaI44LN91OGF1061YIeN6bL5IsFcdp7+oQShRyamE/8fW/CBRWhJAOzI35Dw=="], + "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], @@ -1147,12 +1148,6 @@ "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], - "ripple-address-codec": ["ripple-address-codec@5.0.0", "", { "dependencies": { "@scure/base": "^1.1.3", "@xrplf/isomorphic": "^1.0.0" } }, "sha512-de7osLRH/pt5HX2xw2TRJtbdLLWHu0RXirpQaEeCnWKY5DYHykh3ETSkofvm0aX0LJiV7kwkegJxQkmbO94gWw=="], - - "ripple-binary-codec": ["ripple-binary-codec@2.6.0", "", { "dependencies": { "@xrplf/isomorphic": "^1.0.1", "bignumber.js": "^9.0.0", "ripple-address-codec": "^5.0.0" } }, "sha512-OJBRxjjalO7SrIwydHhcC9wOFLoeKcawoqSEfGZilAtXROYTWHx5kTly2VcUMmMMSEYIh1+yEstBtLBObNjeKQ=="], - - "ripple-keypairs": ["ripple-keypairs@2.0.0", "", { "dependencies": { "@noble/curves": "^1.0.0", "@xrplf/isomorphic": "^1.0.0", "ripple-address-codec": "^5.0.0" } }, "sha512-b5rfL2EZiffmklqZk1W+dvSy97v3V/C7936WxCCgDynaGPp7GE6R2XO7EU9O2LlM/z95rj870IylYnOQs+1Rag=="], - "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], "safe-array-concat": ["safe-array-concat@1.1.3", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", "has-symbols": "^1.1.0", "isarray": "^2.0.5" } }, "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q=="], @@ -1281,6 +1276,8 @@ "victory-vendor": ["victory-vendor@36.9.2", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ=="], + "viem": ["viem@2.47.0", "", { "dependencies": { "@noble/curves": "1.9.1", "@noble/hashes": "1.8.0", "@scure/bip32": "1.7.0", "@scure/bip39": "1.6.0", "abitype": "1.2.3", "isows": "1.0.7", "ox": "0.14.0", "ws": "8.18.3" }, "peerDependencies": { "typescript": ">=5.0.4" }, "optionalPeers": ["typescript"] }, "sha512-jU5e1E1s5E5M1y+YrELDnNar/34U8NXfVcRfxtVETigs2gS1vvW2ngnBoQUGBwLnNr0kNv+NUu4m10OqHByoFw=="], + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="], @@ -1293,9 +1290,7 @@ "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], - "ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="], - - "xrpl": ["xrpl@4.5.0", "", { "dependencies": { "@scure/bip32": "^1.3.1", "@scure/bip39": "^1.2.1", "@xrplf/isomorphic": "^1.0.1", "@xrplf/secret-numbers": "^2.0.0", "bignumber.js": "^9.0.0", "eventemitter3": "^5.0.1", "fast-json-stable-stringify": "^2.1.0", "ripple-address-codec": "^5.0.0", "ripple-binary-codec": "^2.6.0", "ripple-keypairs": "^2.0.0" } }, "sha512-fH3mmRMAiio53JaiPOhbaBLPo9H5qLpgHLkt2YWfrwppjvw70wjRcMBXGB67yEl4KWVi7yDhnZBbrtUdZrNHhA=="], + "ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="], "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], @@ -1529,6 +1524,8 @@ "@radix-ui/react-tooltip/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@scure/bip32/@noble/curves": ["@noble/curves@1.9.7", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="], @@ -1547,8 +1544,6 @@ "@typescript-eslint/typescript-estree/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], - "@xrplf/isomorphic/eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="], - "chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], "cmdk/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="], @@ -1609,6 +1604,8 @@ "next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], + "ox/eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="], + "prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], "radix-ui/@radix-ui/react-aspect-ratio": ["@radix-ui/react-aspect-ratio@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Yq6lvO9HQyPwev1onK1daHCHqXVLzPhSVjmsNjCa2Zcxy2f7uJD2itDtxknv6FzAKCwD1qQkeVDmX/cev13n/g=="], @@ -1645,8 +1642,6 @@ "radix-ui/@radix-ui/react-toggle-group": ["@radix-ui/react-toggle-group@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-toggle": "1.1.10", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q=="], - "recharts/eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="], - "sharp/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], "tinyglobby/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], diff --git a/docker-compose.yml b/docker-compose.yml index 58aa2de..4b3974b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,27 +1,40 @@ version: '3.8' services: + anvil: + image: ghcr.io/foundry-rs/foundry:latest + entrypoint: anvil + command: + - "--host" + - "0.0.0.0" + - "--chain-id" + - "31337" + ports: + - "8545:8545" + healthcheck: + test: ["CMD", "cast", "block-number", "--rpc-url", "http://localhost:8545"] + interval: 5s + timeout: 5s + retries: 10 + start_period: 5s + api: build: context: ./apps/api dockerfile: Dockerfile ports: - "3001:3001" - # environment: - # - PORT=3001 - # - NODE_ENV=development - # - DATABASE_PATH=/data/mitate.db - # - XRPL_RPC_URL=https://s.altnet.rippletest.net:51234 - # - XRPL_WS_URL=wss://s.altnet.rippletest.net:51233 - # - XRPL_NETWORK_ID=1 - # - XRPL_OPERATOR_ADDRESS=${XRPL_OPERATOR_ADDRESS:-} - # - XRPL_ISSUER_ADDRESS=${XRPL_ISSUER_ADDRESS:-} - # - ADMIN_API_KEY=${ADMIN_API_KEY:-dev-admin-key} env_file: - ./apps/api/.env + environment: + - EVM_RPC_URL=http://anvil:8545 + - EVM_CHAIN_ID=31337 volumes: - api-data:/data - ./apps/api/src:/app/src:ro + depends_on: + anvil: + condition: service_healthy healthcheck: test: ["CMD", "bun", "-e", "const r = await fetch('http://localhost:3001/health'); if (!r.ok) process.exit(1)"] interval: 30s diff --git a/docs/ADR.md b/docs/ADR.md index 8282bbe..d8db722 100644 --- a/docs/ADR.md +++ b/docs/ADR.md @@ -1,4 +1,4 @@ -# ADR: XRPL Parimutuel Prediction Market +# ADR: EVM Parimutuel Prediction Market ## Status Accepted — 2025-02-11 @@ -9,16 +9,16 @@ Architecture decisions for a prediction market DApp submitted to the Japan Finan ### Scoring Criteria | Criteria | Weight | |---|---| -| XRPL Functions Used (depth, creativity, sophistication of integration) | 25% | +| EVM Functions Used (depth, creativity, sophistication of integration) | 25% | | Commercial Viability (market need, business model, scalability) | 30% | | Project Completeness (working prototype, documentation quality) | 25% | | Track Depth (domain expertise, track relevance) | 20% | ### Constraints -- Must work on XRPL Testnet +- Must work on EVM Testnet - Demo video: max 3 minutes - Public GitHub repository required -- Heavy use of XRPL-native features is critical ("Generic project that works on any chain" is an explicit disqualifier) +- Effective use of EVM-native features is critical --- @@ -29,46 +29,46 @@ Architecture decisions for a prediction market DApp submitted to the Japan Finan 2. **Parimutuel** ← selected ### Rationale -- **XRPL L1 compatibility**: Parimutuel requires no real-time pricing function on-chain. LMSR requires `b * ln(Σe^(q_i/b))` computations, which XRPL has no mechanism to execute on-chain. +- **EVM compatibility**: Parimutuel requires no real-time pricing function on-chain. LMSR requires `b * ln(Σe^(q_i/b))` computations, which are expensive to execute on-chain. - **Bet weighting compatibility**: Parimutuel integrates weight coefficients naturally by multiplying in the payout formula. LMSR creates contradictions — different users would have different price impacts for the same purchase quantity, breaking fair price discovery and creating arbitrage opportunities. - **Implementation complexity**: Parimutuel is simple ratio arithmetic. Achievable within the hackathon timeline with high confidence. -- **Scoring alignment**: Higher proportion of logic lives on-chain, maximizing the "XRPL Functions Used" score. +- **Scoring alignment**: Higher proportion of logic lives on-chain, maximizing the "EVM Functions Used" score. ### Why LMSR Was Rejected -- Core logic (pricing) would live off-chain, weakening the "XRPL Functions Used" score +- Core logic (pricing) would live off-chain, weakening the "EVM Functions Used" score - Bet weighting integration is architecturally problematic (per-user price impact asymmetry) - Higher implementation risk for hackathon timeline --- -## Decision 2: Execution Layer — XRPL L1 Native + Off-chain Server +## Decision 2: Execution Layer — EVM + Off-chain Server ### Options Considered -1. **XRPL L1 native features + off-chain server** ← selected -2. XRPL EVM Sidechain (Solidity) +1. **EVM native features + off-chain server** ← selected +2. Pure on-chain smart contract (Solidity) ### Rationale -- **XRPL Functions Used (25%)**: L1 native enables Escrow, Issued Currency, DEX, Multi-Sign, Memo — 5+ XRPL-specific features. EVM Sidechain produces Solidity contracts portable to Ethereum/Polygon/Arbitrum, weakening the "why XRPL?" argument. -- **Project Completeness (25%)**: xrpl.js alone is sufficient. No bridge configuration or Solidity toolchain required. -- **Track Depth (20%)**: Demonstrates deep understanding of XRPL-native primitives. +- **EVM Functions Used (25%)**: ETH transfers, calldata, and Multi-Sign provide clear on-chain verifiability without requiring complex Solidity contracts. +- **Project Completeness (25%)**: viem alone is sufficient for interacting with EVM. No additional Solidity toolchain required for the off-chain-server model. +- **Track Depth (20%)**: Demonstrates deep understanding of EVM primitives and transaction mechanics. -### Why EVM Sidechain Was Rejected -- Scoring guidelines explicitly flag "Generic project (works on any chain)" as a pitfall -- Additional complexity (Solidity + Hardhat + bridge setup) without scoring benefit -- Risk of being perceived as "not truly XRPL-native" +### Why Pure On-chain Was Rejected +- Smart contract development adds significant complexity within hackathon timeline +- Off-chain server model with EVM verification is simpler and equally auditable +- Risk of Solidity bugs or gas issues during live demo --- -## Decision 3: Settlement Currency — XRP +## Decision 3: Settlement Currency — ETH ### Options Considered -1. **XRP** ← selected -2. Stablecoin (Issued Currency) +1. **ETH** ← selected +2. Stablecoin (ERC-20) ### Rationale -- **Escrow compatibility**: XRPL Escrow can only lock XRP. Issued Currencies cannot be escrowed — this is a protocol-level constraint. -- **Scoring**: Escrow usage is a strong differentiator for "XRPL Functions Used". -- **Testnet simplicity**: XRP is freely available from the faucet. Stablecoins would require self-issuance, adding setup overhead. +- **Simplicity**: ETH is the native token of EVM chains and requires no token contract deployment. +- **Scoring**: Native ETH transfers demonstrate direct EVM usage. +- **Testnet simplicity**: ETH is freely available from testnet faucets. Stablecoins would require self-deployment of ERC-20 contracts. ### Trade-off - Stablecoin support is desirable for production. This is acknowledged as a future extension and will be mentioned in documentation and demo narrative. @@ -77,39 +77,38 @@ Architecture decisions for a prediction market DApp submitted to the Japan Finan ## Decision 4: On-chain / Off-chain Responsibility Split -### On-chain (XRPL L1) -| Function | XRPL Feature | Purpose | +### On-chain (EVM) +| Function | EVM Feature | Purpose | |---|---|---| -| Fund pool management | **Escrow** | Time-locked XRP deposit for bets | -| Bet recording | **Issued Currency (Trust Line)** | YES/NO outcome token issuance | -| Betting deadline enforcement | **Escrow CancelAfter** | Automatic deadline via time-lock | -| Secondary trading | **DEX (OfferCreate)** | Peer-to-peer outcome token trading | +| Fund pool management | **ETH Transfer** | ETH deposit to operator for bets | +| Bet recording | **calldata** | On-chain bet metadata | +| Betting deadline enforcement | **block_number** | Deadline enforced off-chain against block timestamps | | Resolution governance | **Multi-Sign** | Multi-party market resolution approval | -| Metadata recording | **Memo** | On-ledger bet/market state recording | +| Metadata recording | **calldata** | On-block bet/market state recording | ### Off-chain (Server) | Function | Description | |---|---| | Payout calculation | `totalPool × (betAmount × weight) / Σ(betAmount_i × weight_i)` | -| Payout transaction submission | Individual Payment to each winner | -| Token holder aggregation | Query via XRPL API (`account_lines`) | +| Payout transaction submission | Individual ETH transfer to each winner | +| Bet aggregation | Query via EVM RPC (`eth_getLogs`, `eth_getTransactionByHash`) | | Market management UI | Frontend / Backend | | User attributes & weight management | Off-chain database | ### Trust Guarantees -- All payout calculation inputs (total pool, individual balances) are publicly visible on-ledger, enabling independent post-hoc verification by anyone. -- Market state hashes recorded in Memo fields enable off-chain computation integrity checks. +- All payout calculation inputs (total pool, individual bet amounts) are publicly visible on-chain via calldata, enabling independent post-hoc verification by anyone. +- Market state hashes recorded in calldata enable off-chain computation integrity checks. --- ## Decision 5: Differentiation Features ### In Scope (Hackathon) -1. **Parimutuel prediction market core** — Full bet → resolve → payout flow using XRPL native features. +1. **Parimutuel prediction market core** — Full bet → resolve → payout flow using EVM native features. ### Concept Only (Future Roadmap) 1. **Bet weighting**: Attribute-based weight coefficients in payout calculation. Naturally integrates with Parimutuel: `payout = totalPool × (bet × w) / Σ(bet_i × w_i)`. -2. **Yield integration on deposits**: Redirect locked funds to XRPL DeFi protocols (e.g., AMM liquidity provision) to eliminate opportunity cost. Requires architectural change from Escrow to operator-managed pooling. +2. **Yield integration on deposits**: Redirect locked funds to EVM DeFi protocols (e.g., Aave, Compound) to eliminate opportunity cost. Requires architectural change to operator-managed pooling with yield-bearing positions. --- @@ -120,28 +119,28 @@ Architecture decisions for a prediction market DApp submitted to the Japan Finan | Frontend | Next.js (App Router), deployed on Vercel | | Backend | Node.js / TypeScript (Express or Hono), deployed on Fly.io | | Database | SQLite (file-based, persisted on Fly.io Volume) | -| Blockchain | XRPL Testnet | -| XRPL SDK | xrpl.js | +| Blockchain | EVM Testnet | +| EVM SDK | viem | ### Why Fly.io + SQLite -- Full Node.js runtime with no execution time limits — WebSocket connections to XRPL and long-running operations (e.g., batch EscrowFinish) work without constraints +- Full Node.js runtime with no execution time limits — WebSocket connections to EVM nodes and long-running operations (e.g., batch ETH transfers) work without constraints - Persistent Volumes allow SQLite file storage with zero external DB dependencies - Free tier ($5/month credit) is sufficient for hackathon scale - Deploy via CLI (`fly launch` / `fly deploy`) with automatic Dockerfile generation — minimal ops overhead -- No V8 isolate restrictions — xrpl.js and crypto libraries (e.g., `five-bells-condition`) work out of the box with Node.js `crypto` module +- No V8 isolate restrictions — viem and crypto libraries work out of the box with Node.js `crypto` module --- ## Consequences ### Positive -- 5+ XRPL-specific features utilized, maximizing "XRPL Functions Used" score +- EVM-native ETH transfers and calldata utilized, maximizing on-chain verifiability - Parimutuel simplicity ensures completion within hackathon timeline - Clear extension path to bet weighting and yield integration -- Off-chain payout computation is fully verifiable against on-ledger data -- Full Node.js runtime on Fly.io eliminates platform compatibility concerns with xrpl.js and crypto libraries +- Off-chain payout computation is fully verifiable against on-chain calldata +- Full Node.js runtime on Fly.io eliminates platform compatibility concerns with viem and crypto libraries ### Negative -- Parimutuel lacks real-time price movement (partially compensated by secondary DEX trading) -- XRP-only settlement due to Escrow constraint (stablecoin support requires architectural change) +- Parimutuel lacks real-time price movement (partially compensated by off-chain trading UI) +- ETH-only settlement (stablecoin support requires deploying an ERC-20 contract) - Payout distribution requires trust in the off-chain server (not fully trustless) diff --git a/docs/runbook.md b/docs/runbook.md index 43943a0..82eb0a8 100644 --- a/docs/runbook.md +++ b/docs/runbook.md @@ -1,50 +1,30 @@ # MITATE Operations Runbook -## XRPL Account Setup +## EVM Account Setup ### 1. Create Testnet Accounts +Get test ETH from an EVM testnet faucet: ```bash -# Get test XRP from faucet -curl -X POST https://faucet.altnet.rippletest.net/accounts +# Example: Sepolia faucet +# https://sepoliafaucet.com +# Or generate a wallet with cast (Foundry): +cast wallet new ``` -You need two accounts: -- **Operator**: Holds escrow, receives bets, sends payouts -- **Issuer**: Mints YES/NO tokens +You need one account: +- **Operator**: Receives bets, sends payouts ### 2. Configure Operator Account -```javascript -// Enable rippling for token transfers -{ - "TransactionType": "AccountSet", - "Account": "OPERATOR_ADDRESS", - "SetFlag": 8 // asfDefaultRipple -} - -// Set up multi-sign (2-of-3) -{ - "TransactionType": "SignerListSet", - "Account": "OPERATOR_ADDRESS", - "SignerQuorum": 2, - "SignerEntries": [ - { "SignerEntry": { "Account": "SIGNER_1", "SignerWeight": 1 } }, - { "SignerEntry": { "Account": "SIGNER_2", "SignerWeight": 1 } }, - { "SignerEntry": { "Account": "SIGNER_3", "SignerWeight": 1 } } - ] -} -``` - -### 3. Configure Issuer Account +The operator account is a standard EVM EOA (Externally Owned Account). No on-chain configuration is required beyond funding it with testnet ETH. +For multi-sign governance, configure a Gnosis Safe or similar multi-sig wallet: ```javascript -// Enable rippling for IOU transfers -{ - "TransactionType": "AccountSet", - "Account": "ISSUER_ADDRESS", - "SetFlag": 8 // asfDefaultRipple -} +// Example: Deploy a Gnosis Safe with 2-of-3 signers +// via https://app.safe.global or the Safe SDK +// Owners: [SIGNER_1, SIGNER_2, SIGNER_3] +// Threshold: 2 ``` --- @@ -65,7 +45,7 @@ curl -X POST http://localhost:3001/api/markets \ }' ``` -Response includes `escrowTx` — sign and submit to XRPL. +Response includes the operator address to which bets should be sent. ### Confirm Market Creation @@ -74,8 +54,8 @@ curl -X POST http://localhost:3001/api/markets/mkt_xxx/confirm \ -H "Content-Type: application/json" \ -H "X-Admin-Key: YOUR_ADMIN_KEY" \ -d '{ - "escrowTxHash": "HASH_FROM_XRPL", - "escrowSequence": 12345 + "txHash": "HASH_FROM_EVM", + "block_number": 12345 }' ``` @@ -104,14 +84,14 @@ curl -X POST http://localhost:3001/api/markets/mkt_xxx/resolve \ }' ``` -Response includes `escrowTx` for multi-sign. +Response includes the resolution ETH transfer payload for multi-sign. ### Multi-Sign Process -1. Export transaction blob +1. Export transaction data 2. Send to signer 1 → get partial signature 3. Send to signer 2 → get partial signature -4. Combine signatures → submit to XRPL +4. Combine signatures → submit to EVM node ### Execute Payouts @@ -122,7 +102,7 @@ curl -X POST http://localhost:3001/api/markets/mkt_xxx/payouts \ -d '{ "batchSize": 50 }' ``` -Returns Payment tx payloads. Sign and submit each. +Returns ETH transfer payloads. Sign and submit each. ### Confirm Payouts @@ -132,7 +112,7 @@ curl -X POST http://localhost:3001/api/markets/mkt_xxx/payouts/confirm \ -H "X-Admin-Key: YOUR_ADMIN_KEY" \ -d '{ "payoutId": "pay_xxx", - "txHash": "HASH_FROM_XRPL" + "txHash": "HASH_FROM_EVM" }' ``` @@ -142,29 +122,29 @@ curl -X POST http://localhost:3001/api/markets/mkt_xxx/payouts/confirm \ ### WebSocket Disconnects -The ledger sync service reconnects automatically. Check logs: +The block sync service reconnects automatically. Check logs: ```bash -fly logs -a mitate-api | grep "XRPL" +fly logs -a mitate-api | grep "EVM" ``` ### Bet Confirmation Fails -1. Check user signed both TrustSet and Payment -2. Verify trust line limit is sufficient -3. Check Payment destination is operator address +1. Check user signed the ETH transfer transaction in MetaMask +2. Verify the transaction was sent to the operator address +3. Check calldata encodes the correct market and outcome IDs -### Escrow Finish Fails +### ETH Transfer Fails -1. Ensure CancelAfter has not passed -2. Verify multi-sign quorum is met -3. Check escrow sequence number matches +1. Ensure operator account has sufficient ETH for gas +2. Verify the recipient address is correct +3. Check gas limit is sufficient for the transaction -### Token Mint Fails +### Payout Calculation Mismatch -1. Verify issuer has DefaultRipple enabled -2. Check user's trust line exists and has sufficient limit -3. Ensure issuer has enough XRP reserve +1. Verify all bet transactions are indexed from on-chain calldata +2. Re-run payout calculation against confirmed block data +3. Ensure no bets from after the betting deadline are included --- @@ -181,7 +161,7 @@ sqlite3 /data/mitate.db ".backup /data/backup.db" ```bash curl http://localhost:3001/health -# Returns lastLedgerIndex from system_state +# Returns lastBlockNumber from system_state ``` ### Reset Sync (Danger!) @@ -196,19 +176,18 @@ DELETE FROM system_state WHERE key LIKE 'sync:%'; ### Before Demo -- [ ] Test XRPL accounts have sufficient XRP (>100 XRP each) +- [ ] EVM operator account has sufficient ETH (>0.5 ETH testnet) - [ ] Multi-sign signers have their keys ready - [ ] Create 2-3 demo markets with different deadlines - [ ] Place some test bets on each market - [ ] Verify frontend connects to production API -- [ ] Test wallet connection flow end-to-end +- [ ] Test MetaMask wallet connection flow end-to-end ### Environment Variables **Fly.io** ```bash -fly secrets set XRPL_OPERATOR_ADDRESS=rXXX -fly secrets set XRPL_ISSUER_ADDRESS=rYYY +fly secrets set EVM_OPERATOR_ADDRESS=0xXXX fly secrets set ADMIN_API_KEY=xxx ``` @@ -227,10 +206,10 @@ vercel env add NEXT_PUBLIC_API_URL curl https://mitate-api.fly.dev/health ``` -### XRPL Connection +### EVM Connection ```bash -curl https://mitate-api.fly.dev/health/xrpl +curl https://mitate-api.fly.dev/health/evm ``` ### Logs