From ff9f0246c7b4d24aae2d608784d7bfe202f77d68 Mon Sep 17 00:00:00 2001 From: Sergey Chystiakov Date: Thu, 17 Jul 2025 16:43:27 +0200 Subject: [PATCH 1/5] make a copy of swap board --- examples/swap-board-ml-btc/.env.example | 5 + examples/swap-board-ml-btc/.gitignore | 50 ++ examples/swap-board-ml-btc/README.md | 156 ++++ examples/swap-board-ml-btc/next.config.js | 12 + examples/swap-board-ml-btc/package.json | 33 + examples/swap-board-ml-btc/postcss.config.js | 6 + .../swap-board-ml-btc/prisma/schema.prisma | 45 + .../src/app/api/offers/route.ts | 63 ++ .../src/app/api/swaps/[id]/route.ts | 106 +++ .../src/app/api/swaps/route.ts | 62 ++ .../swap-board-ml-btc/src/app/create/page.tsx | 321 +++++++ .../swap-board-ml-btc/src/app/globals.css | 3 + examples/swap-board-ml-btc/src/app/layout.tsx | 47 + .../swap-board-ml-btc/src/app/offers/page.tsx | 211 +++++ examples/swap-board-ml-btc/src/app/page.tsx | 59 ++ .../src/app/swap/[id]/page.tsx | 834 ++++++++++++++++++ .../swap-board-ml-btc/src/lib/htlc-utils.ts | 168 ++++ examples/swap-board-ml-btc/src/lib/prisma.ts | 9 + examples/swap-board-ml-btc/src/types/swap.ts | 56 ++ examples/swap-board-ml-btc/tailwind.config.js | 27 + examples/swap-board-ml-btc/tsconfig.json | 28 + 21 files changed, 2301 insertions(+) create mode 100644 examples/swap-board-ml-btc/.env.example create mode 100644 examples/swap-board-ml-btc/.gitignore create mode 100644 examples/swap-board-ml-btc/README.md create mode 100644 examples/swap-board-ml-btc/next.config.js create mode 100644 examples/swap-board-ml-btc/package.json create mode 100644 examples/swap-board-ml-btc/postcss.config.js create mode 100644 examples/swap-board-ml-btc/prisma/schema.prisma create mode 100644 examples/swap-board-ml-btc/src/app/api/offers/route.ts create mode 100644 examples/swap-board-ml-btc/src/app/api/swaps/[id]/route.ts create mode 100644 examples/swap-board-ml-btc/src/app/api/swaps/route.ts create mode 100644 examples/swap-board-ml-btc/src/app/create/page.tsx create mode 100644 examples/swap-board-ml-btc/src/app/globals.css create mode 100644 examples/swap-board-ml-btc/src/app/layout.tsx create mode 100644 examples/swap-board-ml-btc/src/app/offers/page.tsx create mode 100644 examples/swap-board-ml-btc/src/app/page.tsx create mode 100644 examples/swap-board-ml-btc/src/app/swap/[id]/page.tsx create mode 100644 examples/swap-board-ml-btc/src/lib/htlc-utils.ts create mode 100644 examples/swap-board-ml-btc/src/lib/prisma.ts create mode 100644 examples/swap-board-ml-btc/src/types/swap.ts create mode 100644 examples/swap-board-ml-btc/tailwind.config.js create mode 100644 examples/swap-board-ml-btc/tsconfig.json diff --git a/examples/swap-board-ml-btc/.env.example b/examples/swap-board-ml-btc/.env.example new file mode 100644 index 0000000..810b817 --- /dev/null +++ b/examples/swap-board-ml-btc/.env.example @@ -0,0 +1,5 @@ +# Database +DATABASE_URL="file:./dev.db" + +# Mintlayer Network (testnet or mainnet) +NEXT_PUBLIC_MINTLAYER_NETWORK="testnet" diff --git a/examples/swap-board-ml-btc/.gitignore b/examples/swap-board-ml-btc/.gitignore new file mode 100644 index 0000000..19ee168 --- /dev/null +++ b/examples/swap-board-ml-btc/.gitignore @@ -0,0 +1,50 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js +.yarn/install-state.gz + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env*.local +.env + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +# database +prisma/dev.db +prisma/dev.db-journal + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# OS +Thumbs.db diff --git a/examples/swap-board-ml-btc/README.md b/examples/swap-board-ml-btc/README.md new file mode 100644 index 0000000..4b84012 --- /dev/null +++ b/examples/swap-board-ml-btc/README.md @@ -0,0 +1,156 @@ +# Mintlayer P2P Swap Board + +A minimal peer-to-peer token swap board for Mintlayer tokens using HTLC (Hash Time Locked Contracts) atomic swaps. + +## Features + +- **Create Swap Offers**: Post your intent to swap one Mintlayer token for another +- **Browse & Accept Offers**: View available offers and accept the ones that interest you +- **Atomic Swaps**: Secure token exchanges using HTLC contracts via mintlayer-connect-sdk +- **Status Tracking**: Real-time monitoring of swap progress with clear status indicators +- **Wallet Integration**: Connect with Mojito wallet for seamless transactions + +## Tech Stack + +- **Frontend**: Next.js 14 (App Router) + React + Tailwind CSS +- **Backend**: Next.js API routes +- **Database**: SQLite with Prisma ORM +- **Blockchain**: Mintlayer Connect SDK for HTLC operations +- **Package Manager**: pnpm (workspace integration) + +## Getting Started + +### Prerequisites + +- Node.js 18+ +- pnpm +- Mojito wallet extension + +### Installation + +1. Install dependencies: +```bash +cd examples/swap-board-ml-ml +pnpm install +``` + +2. Set up the database: +```bash +pnpm db:generate +pnpm db:push +``` + +3. Copy environment variables: +```bash +cp .env.example .env.local +``` + +4. Start the development server: +```bash +pnpm dev +``` + +5. Open [http://localhost:3000](http://localhost:3000) in your browser + +## Usage + +### Creating an Offer + +1. Navigate to `/create` +2. Connect your Mojito wallet +3. Fill in the swap details: + - Token to give (Token ID) + - Amount to give + - Token to receive (Token ID) + - Amount to receive + - Optional contact information +4. Submit the offer + +### Accepting an Offer + +1. Browse offers at `/offers` +2. Connect your wallet +3. Click "Accept Offer" on any available offer +4. You'll be redirected to the swap progress page + +### Monitoring Swaps + +1. Visit `/swap/[id]` to track swap progress +2. The page shows: + - Current swap status + - Progress steps + - Next actions required + - HTLC details when available + +## Swap Process + +1. **Offer Created**: User posts swap intention +2. **Offer Accepted**: Another user accepts the offer +3. **HTLC Creation**: Creator creates initial HTLC with secret hash +4. **Counterparty HTLC**: Taker creates matching HTLC +5. **Token Claiming**: Both parties reveal secrets to claim tokens +6. **Completion**: Swap finalized or manually refunded after timelock expires + +## Database Schema + +### Offer Model +- Stores swap offers with token details and creator information +- Tracks offer status (open, taken, completed, cancelled) + +### Swap Model +- Manages active swaps linked to offers +- Stores HTLC secrets, transaction hashes, and status updates +- Tracks swap progress from pending to completion + +## API Endpoints + +- `GET/POST /api/offers` - List and create swap offers +- `POST /api/swaps` - Accept an offer (creates new swap) +- `GET/POST /api/swaps/[id]` - Get and update swap status + +## Development + +### Database Operations + +```bash +# Generate Prisma client +pnpm db:generate + +# Push schema changes +pnpm db:push + +# Open database browser +pnpm db:studio +``` + +### Building + +```bash +# Build for production +pnpm build + +# Start production server +pnpm start +``` + +## Security Considerations + +- HTLC contracts provide atomic swap guarantees +- Timelock mechanisms prevent indefinite locks - users must manually refund after expiry +- No private keys are stored in the database +- All transactions require wallet confirmation + +## Contributing + +This is a minimal example implementation. For production use, consider: + +- Enhanced error handling and validation +- Comprehensive testing suite +- Rate limiting and spam protection +- Advanced UI/UX improvements +- Mobile responsiveness optimization +- Real-time notifications + +## License + +This project is part of the Mintlayer Connect SDK examples. diff --git a/examples/swap-board-ml-btc/next.config.js b/examples/swap-board-ml-btc/next.config.js new file mode 100644 index 0000000..2bffbb7 --- /dev/null +++ b/examples/swap-board-ml-btc/next.config.js @@ -0,0 +1,12 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + webpack: (config) => { + config.experiments = { + ...config.experiments, + asyncWebAssembly: true, + }; + return config; + }, +} + +module.exports = nextConfig diff --git a/examples/swap-board-ml-btc/package.json b/examples/swap-board-ml-btc/package.json new file mode 100644 index 0000000..3ecd72d --- /dev/null +++ b/examples/swap-board-ml-btc/package.json @@ -0,0 +1,33 @@ +{ + "name": "swap-board-ml-btc", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint", + "db:generate": "prisma generate", + "db:push": "prisma db push", + "db:studio": "prisma studio" + }, + "dependencies": { + "@mintlayer/sdk": "workspace:*", + "@prisma/client": "^5.7.0", + "next": "14.0.4", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "prisma": "^5.7.0" + }, + "devDependencies": { + "@types/node": "^20.10.0", + "@types/react": "^18.2.0", + "@types/react-dom": "^18.2.0", + "autoprefixer": "^10.4.16", + "eslint": "^8.55.0", + "eslint-config-next": "14.0.4", + "postcss": "^8.4.32", + "tailwindcss": "^3.3.6", + "typescript": "^5.3.0" + } +} diff --git a/examples/swap-board-ml-btc/postcss.config.js b/examples/swap-board-ml-btc/postcss.config.js new file mode 100644 index 0000000..33ad091 --- /dev/null +++ b/examples/swap-board-ml-btc/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/examples/swap-board-ml-btc/prisma/schema.prisma b/examples/swap-board-ml-btc/prisma/schema.prisma new file mode 100644 index 0000000..b722594 --- /dev/null +++ b/examples/swap-board-ml-btc/prisma/schema.prisma @@ -0,0 +1,45 @@ +// This is your Prisma schema file, +// learn more about it in the docs: https://pris.ly/d/prisma-schema + +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "sqlite" + url = "file:./dev.db" +} + +model Offer { + id Int @id @default(autoincrement()) + direction String // "tokenA->tokenB" + tokenA String + tokenB String + amountA String + amountB String + price Float + creatorMLAddress String + contact String? + status String @default("open") // open, taken, completed, cancelled + createdAt DateTime @default(now()) + + swaps Swap[] +} + +model Swap { + id Int @id @default(autoincrement()) + offerId Int + takerMLAddress String + status String @default("pending") // pending, htlc_created, in_progress, completed, fully_completed, refunded + secretHash String? + secret String? + creatorHtlcTxHash String? // Creator's HTLC transaction ID + creatorHtlcTxHex String? // Creator's signed HTLC transaction hex + takerHtlcTxHash String? // Taker's HTLC transaction ID + takerHtlcTxHex String? // Taker's signed HTLC transaction hex + claimTxHash String? // Final claim transaction ID + claimTxHex String? // Final claim transaction hex (needed for secret extraction) + createdAt DateTime @default(now()) + + offer Offer @relation(fields: [offerId], references: [id]) +} diff --git a/examples/swap-board-ml-btc/src/app/api/offers/route.ts b/examples/swap-board-ml-btc/src/app/api/offers/route.ts new file mode 100644 index 0000000..56eac3f --- /dev/null +++ b/examples/swap-board-ml-btc/src/app/api/offers/route.ts @@ -0,0 +1,63 @@ +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' +import { CreateOfferRequest } from '@/types/swap' + +export async function GET() { + try { + const offers = await prisma.offer.findMany({ + where: { + status: 'open' + }, + orderBy: { + createdAt: 'desc' + } + }) + + return NextResponse.json(offers) + } catch (error) { + console.error('Error fetching offers:', error) + return NextResponse.json( + { error: 'Failed to fetch offers' }, + { status: 500 } + ) + } +} + +export async function POST(request: NextRequest) { + try { + const body: CreateOfferRequest = await request.json() + + // Validate required fields + if (!body.tokenA || !body.tokenB || !body.amountA || !body.amountB || !body.creatorMLAddress) { + return NextResponse.json( + { error: 'Missing required fields' }, + { status: 400 } + ) + } + + // Calculate price (amountB / amountA) + const price = parseFloat(body.amountB) / parseFloat(body.amountA) + + const offer = await prisma.offer.create({ + data: { + direction: `${body.tokenA}->${body.tokenB}`, + tokenA: body.tokenA, + tokenB: body.tokenB, + amountA: body.amountA, + amountB: body.amountB, + price: price, + creatorMLAddress: body.creatorMLAddress, + contact: body.contact || null, + status: 'open' + } + }) + + return NextResponse.json(offer, { status: 201 }) + } catch (error) { + console.error('Error creating offer:', error) + return NextResponse.json( + { error: 'Failed to create offer' }, + { status: 500 } + ) + } +} diff --git a/examples/swap-board-ml-btc/src/app/api/swaps/[id]/route.ts b/examples/swap-board-ml-btc/src/app/api/swaps/[id]/route.ts new file mode 100644 index 0000000..363d63b --- /dev/null +++ b/examples/swap-board-ml-btc/src/app/api/swaps/[id]/route.ts @@ -0,0 +1,106 @@ +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' +import { UpdateSwapRequest } from '@/types/swap' + +export async function GET( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const swapId = parseInt(params.id) + + if (isNaN(swapId)) { + return NextResponse.json( + { error: 'Invalid swap ID' }, + { status: 400 } + ) + } + + const swap = await prisma.swap.findUnique({ + where: { id: swapId }, + include: { + offer: true + } + }) + + if (!swap) { + return NextResponse.json( + { error: 'Swap not found' }, + { status: 404 } + ) + } + + return NextResponse.json(swap) + } catch (error) { + console.error('Error fetching swap:', error) + return NextResponse.json( + { error: 'Failed to fetch swap' }, + { status: 500 } + ) + } +} + +export async function POST( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const swapId = parseInt(params.id) + const body: UpdateSwapRequest = await request.json() + + if (isNaN(swapId)) { + return NextResponse.json( + { error: 'Invalid swap ID' }, + { status: 400 } + ) + } + + // Check if swap exists + const existingSwap = await prisma.swap.findUnique({ + where: { id: swapId } + }) + + if (!existingSwap) { + return NextResponse.json( + { error: 'Swap not found' }, + { status: 404 } + ) + } + + // Update swap with provided fields + const updateData: any = {} + if (body.status) updateData.status = body.status + if (body.secretHash) updateData.secretHash = body.secretHash + if (body.secret) updateData.secret = body.secret + if (body.creatorHtlcTxHash) updateData.creatorHtlcTxHash = body.creatorHtlcTxHash + if (body.creatorHtlcTxHex) updateData.creatorHtlcTxHex = body.creatorHtlcTxHex + if (body.takerHtlcTxHash) updateData.takerHtlcTxHash = body.takerHtlcTxHash + if (body.takerHtlcTxHex) updateData.takerHtlcTxHex = body.takerHtlcTxHex + if (body.claimTxHash) updateData.claimTxHash = body.claimTxHash + if (body.claimTxHex) updateData.claimTxHex = body.claimTxHex + + const updatedSwap = await prisma.swap.update({ + where: { id: swapId }, + data: updateData, + include: { + offer: true + } + }) + + // If swap is completed, update offer status + if (body.status === 'completed') { + await prisma.offer.update({ + where: { id: updatedSwap.offerId }, + data: { status: 'completed' } + }) + } + + return NextResponse.json(updatedSwap) + } catch (error) { + console.error('Error updating swap:', error) + return NextResponse.json( + { error: 'Failed to update swap' }, + { status: 500 } + ) + } +} diff --git a/examples/swap-board-ml-btc/src/app/api/swaps/route.ts b/examples/swap-board-ml-btc/src/app/api/swaps/route.ts new file mode 100644 index 0000000..6ace226 --- /dev/null +++ b/examples/swap-board-ml-btc/src/app/api/swaps/route.ts @@ -0,0 +1,62 @@ +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' +import { AcceptOfferRequest } from '@/types/swap' + +export async function POST(request: NextRequest) { + try { + const body: AcceptOfferRequest = await request.json() + + // Validate required fields + if (!body.offerId || !body.takerMLAddress) { + return NextResponse.json( + { error: 'Missing required fields' }, + { status: 400 } + ) + } + + // Check if offer exists and is open + const offer = await prisma.offer.findUnique({ + where: { id: body.offerId } + }) + + if (!offer) { + return NextResponse.json( + { error: 'Offer not found' }, + { status: 404 } + ) + } + + if (offer.status !== 'open') { + return NextResponse.json( + { error: 'Offer is no longer available' }, + { status: 400 } + ) + } + + // Create swap and update offer status + const [swap] = await prisma.$transaction([ + prisma.swap.create({ + data: { + offerId: body.offerId, + takerMLAddress: body.takerMLAddress, + status: 'pending' + }, + include: { + offer: true + } + }), + prisma.offer.update({ + where: { id: body.offerId }, + data: { status: 'taken' } + }) + ]) + + return NextResponse.json(swap, { status: 201 }) + } catch (error) { + console.error('Error accepting offer:', error) + return NextResponse.json( + { error: 'Failed to accept offer' }, + { status: 500 } + ) + } +} diff --git a/examples/swap-board-ml-btc/src/app/create/page.tsx b/examples/swap-board-ml-btc/src/app/create/page.tsx new file mode 100644 index 0000000..673bbe8 --- /dev/null +++ b/examples/swap-board-ml-btc/src/app/create/page.tsx @@ -0,0 +1,321 @@ +'use client' + +import { useState, useEffect } from 'react' +import { Client } from '@mintlayer/sdk' + +interface Token { + token_id: string + symbol: string + number_of_decimals: number +} + +export default function CreateOfferPage() { + const [formData, setFormData] = useState({ + tokenA: '', + tokenB: '', + amountA: '', + amountB: '', + contact: '' + }) + const [loading, setLoading] = useState(false) + const [userAddress, setUserAddress] = useState('') + const [client, setClient] = useState(null) + const [tokens, setTokens] = useState([]) + const [loadingTokens, setLoadingTokens] = useState(true) + + useEffect(() => { + initializeClient() + fetchTokens() + }, []) + + const initializeClient = async () => { + try { + const network = (process.env.NEXT_PUBLIC_MINTLAYER_NETWORK as 'testnet' | 'mainnet') || 'testnet' + const newClient = await Client.create({ network }) + setClient(newClient) + } catch (error) { + console.error('Error initializing client:', error) + } + } + + const fetchTokens = async () => { + try { + setLoadingTokens(true) + const network = (process.env.NEXT_PUBLIC_MINTLAYER_NETWORK as 'testnet' | 'mainnet') || 'testnet' + const networkId = network === 'mainnet' ? 0 : 1 + const response = await fetch(`https://api.mintini.app/dex_tokens?network=${networkId}`) + + if (response.ok) { + const tokenData = await response.json() + // Add native ML token at the beginning + const allTokens = [ + { token_id: 'ML', symbol: 'ML', number_of_decimals: 11 }, + ...tokenData + ] + setTokens(allTokens) + } + } catch (error) { + console.error('Error fetching tokens:', error) + } finally { + setLoadingTokens(false) + } + } + + const connectWallet = async () => { + try { + if (client) { + const connect = await client.connect() + const address = connect.testnet.receiving[0] + setUserAddress(address) + } + } catch (error) { + console.error('Error connecting wallet:', error) + alert('Failed to connect wallet. Please make sure Mojito wallet is installed.') + } + } + + const handleInputChange = (e: React.ChangeEvent) => { + const { name, value } = e.target + setFormData(prev => ({ + ...prev, + [name]: value + })) + } + + const getTokenDisplay = (tokenId: string) => { + if (tokenId === 'ML') return 'ML (Native)' + const token = tokens.find(t => t.token_id === tokenId) + if (!token) return tokenId + const shortId = token.token_id.slice(-8) // Last 8 characters + return `${token.symbol} (...${shortId})` + } + + const getSelectedToken = (tokenId: string) => { + return tokens.find(t => t.token_id === tokenId) + } + + const calculatePrice = () => { + if (formData.amountA && formData.amountB) { + const price = parseFloat(formData.amountB) / parseFloat(formData.amountA) + return price.toFixed(6) + } + return '0' + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + + if (!userAddress) { + alert('Please connect your wallet first') + return + } + + if (!formData.tokenA || !formData.tokenB || !formData.amountA || !formData.amountB) { + alert('Please fill in all required fields') + return + } + + setLoading(true) + try { + const response = await fetch('/api/offers', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + ...formData, + creatorMLAddress: userAddress, + }), + }) + + if (response.ok) { + alert('Offer created successfully!') + // Reset form + setFormData({ + tokenA: '', + tokenB: '', + amountA: '', + amountB: '', + contact: '' + }) + // Redirect to offers page + window.location.href = '/offers' + } else { + const error = await response.json() + alert(`Error: ${error.error}`) + } + } catch (error) { + console.error('Error creating offer:', error) + alert('Failed to create offer') + } finally { + setLoading(false) + } + } + + return ( +
+

Create Swap Offer

+ +
+
+
+
+ + {loadingTokens ? ( +
+ Loading tokens... +
+ ) : ( + + )} + {formData.tokenA && getSelectedToken(formData.tokenA) && ( +
+ Decimals: {getSelectedToken(formData.tokenA)?.number_of_decimals} +
+ )} +
+ +
+ + +
+
+ +
+
+ + {loadingTokens ? ( +
+ Loading tokens... +
+ ) : ( + + )} + {formData.tokenB && getSelectedToken(formData.tokenB) && ( +
+ Decimals: {getSelectedToken(formData.tokenB)?.number_of_decimals} +
+ )} +
+ +
+ + +
+
+ + {formData.amountA && formData.amountB && ( +
+

+ Exchange Rate: 1 {formData.tokenA || 'TokenA'} = {calculatePrice()} {formData.tokenB || 'TokenB'} +

+
+ )} + +
+ + +
+ +
+

+ Your Mintlayer Address: +

+ {userAddress ? ( +

+ {userAddress} +

+ ) : ( +
+

Not connected

+ +
+ )} +
+ + +
+
+
+ ) +} diff --git a/examples/swap-board-ml-btc/src/app/globals.css b/examples/swap-board-ml-btc/src/app/globals.css new file mode 100644 index 0000000..b5c61c9 --- /dev/null +++ b/examples/swap-board-ml-btc/src/app/globals.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/examples/swap-board-ml-btc/src/app/layout.tsx b/examples/swap-board-ml-btc/src/app/layout.tsx new file mode 100644 index 0000000..e7fa70f --- /dev/null +++ b/examples/swap-board-ml-btc/src/app/layout.tsx @@ -0,0 +1,47 @@ +import type { Metadata } from 'next' +import { Inter } from 'next/font/google' +import './globals.css' + +const inter = Inter({ subsets: ['latin'] }) + +export const metadata: Metadata = { + title: 'Mintlayer P2P Swap Board', + description: 'Peer-to-peer token swaps on Mintlayer using HTLC atomic swaps', +} + +export default function RootLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( + + +
+ +
+ {children} +
+
+ + + ) +} diff --git a/examples/swap-board-ml-btc/src/app/offers/page.tsx b/examples/swap-board-ml-btc/src/app/offers/page.tsx new file mode 100644 index 0000000..cf18ca5 --- /dev/null +++ b/examples/swap-board-ml-btc/src/app/offers/page.tsx @@ -0,0 +1,211 @@ +'use client' + +import { useState, useEffect } from 'react' +import { Client } from '@mintlayer/sdk' +import { Offer } from '@/types/swap' + +interface Token { + token_id: string + symbol: string + number_of_decimals: number +} + +export default function OffersPage() { + const [offers, setOffers] = useState([]) + const [loading, setLoading] = useState(true) + const [accepting, setAccepting] = useState(null) + const [client, setClient] = useState(null) + const [userAddress, setUserAddress] = useState('') + const [tokens, setTokens] = useState([]) + + useEffect(() => { + fetchOffers() + initializeClient() + fetchTokens() + }, []) + + const initializeClient = async () => { + try { + const network = (process.env.NEXT_PUBLIC_MINTLAYER_NETWORK as 'testnet' | 'mainnet') || 'testnet' + const newClient = await Client.create({ network }) + setClient(newClient) + } catch (error) { + console.error('Error initializing client:', error) + } + } + + const fetchTokens = async () => { + try { + const network = (process.env.NEXT_PUBLIC_MINTLAYER_NETWORK as 'testnet' | 'mainnet') || 'testnet' + const networkId = network === 'mainnet' ? 0 : 1 + const response = await fetch(`https://api.mintini.app/dex_tokens?network=${networkId}`) + + if (response.ok) { + const tokenData = await response.json() + const allTokens = [ + { token_id: 'ML', symbol: 'ML', number_of_decimals: 11 }, + ...tokenData + ] + setTokens(allTokens) + } + } catch (error) { + console.error('Error fetching tokens:', error) + } + } + + const fetchOffers = async () => { + try { + const response = await fetch('/api/offers') + const data = await response.json() + setOffers(data) + } catch (error) { + console.error('Error fetching offers:', error) + } finally { + setLoading(false) + } + } + + const connectWallet = async () => { + try { + if (client) { + const connect = await client.connect() + const address = connect.testnet.receiving[0] + setUserAddress(address) + } + } catch (error) { + console.error('Error connecting wallet:', error) + alert('Failed to connect wallet. Please make sure Mojito wallet is installed.') + } + } + + const getTokenSymbol = (tokenId: string) => { + if (tokenId === 'ML') return 'ML' + const token = tokens.find(t => t.token_id === tokenId) + return token ? token.symbol : tokenId.slice(-8) + } + + const acceptOffer = async (offerId: number) => { + if (!userAddress) { + alert('Please connect your wallet first') + return + } + + setAccepting(offerId) + try { + const response = await fetch('/api/swaps', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + offerId, + takerMLAddress: userAddress, + }), + }) + + if (response.ok) { + const swap = await response.json() + // Redirect to swap page + window.location.href = `/swap/${swap.id}` + } else { + const error = await response.json() + alert(`Error: ${error.error}`) + } + } catch (error) { + console.error('Error accepting offer:', error) + alert('Failed to accept offer') + } finally { + setAccepting(null) + } + } + + if (loading) { + return ( +
+
Loading offers...
+
+ ) + } + + return ( +
+
+

Available Offers

+
+ {userAddress ? ( +
+ Connected: {userAddress.slice(0, 10)}... +
+ ) : ( + + )} +
+
+ + {offers.length === 0 ? ( +
+

No offers available at the moment.

+ + Create the first offer + +
+ ) : ( +
+ {offers.map((offer) => ( +
+
+
+
+ + {offer.amountA} {getTokenSymbol(offer.tokenA)} + + + + + + {offer.amountB} {getTokenSymbol(offer.tokenB)} + +
+
+ Price: {offer.price.toFixed(6)} {getTokenSymbol(offer.tokenB)}/{getTokenSymbol(offer.tokenA)} +
+
+ Creator: {offer.creatorMLAddress.slice(0, 20)}... +
+ {offer.contact && ( +
+ Contact: {offer.contact} +
+ )} +
+ Created: {new Date(offer.createdAt).toLocaleString()} +
+
+
+ + {offer.creatorMLAddress === userAddress && ( +
Your offer
+ )} +
+
+
+ ))} +
+ )} +
+ ) +} diff --git a/examples/swap-board-ml-btc/src/app/page.tsx b/examples/swap-board-ml-btc/src/app/page.tsx new file mode 100644 index 0000000..e2edab6 --- /dev/null +++ b/examples/swap-board-ml-btc/src/app/page.tsx @@ -0,0 +1,59 @@ +import Link from 'next/link' + +export default function Home() { + return ( +
+
+

+ Mintlayer P2P Swap Board +

+

+ Trade Mintlayer tokens directly with other users using secure HTLC atomic swaps +

+ +
+ +
+
+ + + +
+

Browse Offers

+

+ View available token swap offers from other users. +

+
+ + + +
+
+ + + +
+

Create Offer

+

+ Post your own token swap offer and wait for other users to accept it. +

+
+ +
+ +
+

How it works

+
+
    +
  1. Create or browse token swap offers
  2. +
  3. Accept an offer to initiate an atomic swap
  4. +
  5. Both parties create Hash Time Locked Contracts (HTLCs)
  6. +
  7. Exchange secrets to claim tokens securely
  8. +
  9. Manual refund available if swap fails after timelock expires
  10. +
+
+
+
+
+ ) +} diff --git a/examples/swap-board-ml-btc/src/app/swap/[id]/page.tsx b/examples/swap-board-ml-btc/src/app/swap/[id]/page.tsx new file mode 100644 index 0000000..bb53d96 --- /dev/null +++ b/examples/swap-board-ml-btc/src/app/swap/[id]/page.tsx @@ -0,0 +1,834 @@ +'use client' + +import { useState, useEffect } from 'react' +import { Client } from '@mintlayer/sdk' +import { Swap } from '@/types/swap' + +export default function SwapPage({ params }: { params: { id: string } }) { + const [swap, setSwap] = useState(null) + const [loading, setLoading] = useState(true) + const [userAddress, setUserAddress] = useState('') + const [client, setClient] = useState(null) + const [secretHash, setSecretHash] = useState(null) + const [generatingSecret, setGeneratingSecret] = useState(false) + const [creatingHtlc, setCreatingHtlc] = useState(false) + const [creatingCounterpartyHtlc, setCreatingCounterpartyHtlc] = useState(false) + const [claimingHtlc, setClaimingHtlc] = useState(false) + const [tokens, setTokens] = useState([]) + + useEffect(() => { + fetchSwap() + initializeClient() + fetchTokens() + + // Poll for updates every 10 seconds + const interval = setInterval(fetchSwap, 10000) + return () => clearInterval(interval) + }, [params.id]) + + const initializeClient = async () => { + try { + const network = (process.env.NEXT_PUBLIC_MINTLAYER_NETWORK as 'testnet' | 'mainnet') || 'testnet' + const newClient = await Client.create({ network }) + setClient(newClient) + } catch (error) { + console.error('Error initializing client:', error) + } + } + + const fetchTokens = async () => { + try { + const network = (process.env.NEXT_PUBLIC_MINTLAYER_NETWORK as 'testnet' | 'mainnet') || 'testnet' + const networkId = network === 'mainnet' ? 0 : 1 + const response = await fetch(`https://api.mintini.app/dex_tokens?network=${networkId}`) + + if (response.ok) { + const tokenData = await response.json() + const allTokens = [ + { token_id: 'ML', symbol: 'ML', number_of_decimals: 11 }, + ...tokenData + ] + setTokens(allTokens) + } + } catch (error) { + console.error('Error fetching tokens:', error) + } + } + + const getTokenSymbol = (tokenId: string) => { + if (tokenId === 'ML') return 'ML' + const token = tokens.find((t: any) => t.token_id === tokenId) + return token ? token.symbol : tokenId.slice(-8) + } + + const fetchSwap = async () => { + try { + const response = await fetch(`/api/swaps/${params.id}`) + if (response.ok) { + const data = await response.json() + setSwap(data) + } + } catch (error) { + console.error('Error fetching swap:', error) + } finally { + setLoading(false) + } + } + + const connectWallet = async () => { + try { + if (client) { + const connect = await client.connect() + const address = connect.testnet.receiving[0] + setUserAddress(address) + } + } catch (error) { + console.error('Error connecting wallet:', error) + alert('Failed to connect wallet. Please make sure Mojito wallet is installed.') + } + } + + const generateSecretHash = async () => { + if (!client) { + alert('Please connect your wallet first') + return + } + + setGeneratingSecret(true) + try { + const secretHashResponse = await client.requestSecretHash({}) + setSecretHash(secretHashResponse) + console.log('Generated secret hash:', secretHashResponse) + } catch (error) { + console.error('Error generating secret hash:', error) + alert('Failed to generate secret hash. Please try again.') + } finally { + setGeneratingSecret(false) + } + } + + const createHtlc = async () => { + if (!client || !userAddress || !secretHash || !swap?.offer) { + alert('Missing required data for HTLC creation') + return + } + + setCreatingHtlc(true) + try { + // Step 1: Build the HTLC transaction + const htlcParams = { + amount: swap.offer.amountA, + token_id: swap.offer.tokenA === 'ML' ? null : swap.offer.tokenA, + secret_hash: { hex: secretHash.secret_hash_hex }, + spend_address: swap.takerMLAddress, // Taker can spend with secret + refund_address: userAddress, // Creator can refund after timelock + refund_timelock: { + type: 'ForBlockCount', + content: 144 // ~24 hours assuming 10min blocks + } + } + + // Step 2: Sign the transaction + const signedTxHex = await client.createHtlc(htlcParams) + console.log('HTLC signed:', signedTxHex) + + // Step 3: Broadcast the transaction to the network + const broadcastResult = await client.broadcastTx(signedTxHex) + console.log('HTLC broadcast result:', broadcastResult) + + const txId = broadcastResult.tx_id || broadcastResult.transaction_id || broadcastResult.id + + // Step 4: Update swap status with transaction ID and hex + // Note: We save the signed transaction hex because it's needed later + // to extract the secret when someone claims the HTLC using extractHtlcSecret() + await fetch(`/api/swaps/${swap.id}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + status: 'htlc_created', + secretHash: JSON.stringify(secretHash), + creatorHtlcTxHash: txId, + creatorHtlcTxHex: signedTxHex + }) + }) + + // Refresh swap data + fetchSwap() + alert(`HTLC created and broadcasted successfully! TX ID: ${txId}`) + } catch (error) { + console.error('Error creating HTLC:', error) + alert('Failed to create HTLC. Please try again.') + } finally { + setCreatingHtlc(false) + } + } + + const createCounterpartyHtlc = async () => { + if (!client || !userAddress || !swap?.offer || !swap.secretHash) { + alert('Missing required data for counterparty HTLC creation') + return + } + + setCreatingCounterpartyHtlc(true) + try { + // Parse the stored secret hash from the creator's HTLC + const creatorSecretHash = JSON.parse(swap.secretHash) + + const htlcParams = { + amount: swap.offer.amountB, // Taker gives amountB + token_id: swap.offer.tokenB === 'ML' ? null : swap.offer.tokenB, + secret_hash: { hex: creatorSecretHash.secret_hash_hex }, // Use same secret hash + spend_address: swap.offer.creatorMLAddress, // Creator can spend with secret + refund_address: userAddress, // Taker can refund after timelock + refund_timelock: { + type: 'ForBlockCount', + content: 144 // ~24 hours assuming 10min blocks + } + } + + // Step 2: Sign the transaction + const signedTxHex = await client.createHtlc(htlcParams) + console.log('Counterparty HTLC signed:', signedTxHex) + + // Step 3: Broadcast the transaction to the network + const broadcastResult = await client.broadcastTx(signedTxHex) + console.log('Counterparty HTLC broadcast result:', broadcastResult) + + const txId = broadcastResult.tx_id || broadcastResult.transaction_id || broadcastResult.id + + // Step 4: Update swap status with transaction ID and hex + // Note: We save the signed transaction hex because it's needed later + // to extract the secret when someone claims the HTLC using extractHtlcSecret() + await fetch(`/api/swaps/${swap.id}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + status: 'in_progress', + takerHtlcTxHash: txId, + takerHtlcTxHex: signedTxHex + }) + }) + + // Refresh swap data + fetchSwap() + alert(`Counterparty HTLC created and broadcasted successfully! TX ID: ${txId}`) + } catch (error) { + console.error('Error creating counterparty HTLC:', error) + alert('Failed to create counterparty HTLC. Please try again.') + } finally { + setCreatingCounterpartyHtlc(false) + } + } + + const claimHtlc = async () => { + if (!client || !userAddress || !swap?.offer) { + alert('Missing required data for HTLC claiming') + return + } + + // Determine which HTLC to claim based on user role + const isUserCreator = swap.offer.creatorMLAddress === userAddress + const isUserTaker = swap.takerMLAddress === userAddress + + if (!isUserCreator && !isUserTaker) { + alert('You are not authorized to claim this HTLC') + return + } + + setClaimingHtlc(true) + try { + let htlcTxHash: string + + if (isUserCreator) { + // Creator claims taker's HTLC using the secret stored in their wallet + if (!swap.takerHtlcTxHash) { + alert('Taker HTLC not found') + return + } + htlcTxHash = swap.takerHtlcTxHash + // The wallet will automatically use the secret it generated earlier + // We don't need to provide it explicitly - the wallet knows it + } else { + // Taker claims creator's HTLC (needs to provide the secret they learned) + if (!swap.creatorHtlcTxHash) { + alert('Creator HTLC not found') + return + } + htlcTxHash = swap.creatorHtlcTxHash + // For the taker, they need to input the secret they obtained somehow + // In a real implementation, they would have learned this secret from somewhere + const inputSecret = prompt('Enter the secret to claim the HTLC:') + if (!inputSecret) { + alert('Secret is required to claim HTLC') + return + } + + // Build spend HTLC parameters for taker (with secret) + const spendParams = { + transaction_id: htlcTxHash, + secret: inputSecret + } + + // Step 1: Sign the spend transaction + const signedSpendTxHex = await client.spendHtlc(spendParams) + console.log('HTLC spend signed:', signedSpendTxHex) + + // Step 2: Broadcast the spend transaction + const broadcastResult = await client.broadcastTx(signedSpendTxHex) + console.log('HTLC spend broadcast result:', broadcastResult) + + const spendTxId = broadcastResult.tx_id || broadcastResult.transaction_id || broadcastResult.id + + // Step 3: Update swap status with both transaction ID and hex + // Note: We save the claim transaction hex because it's needed to extract the secret + const updateData: any = { + status: 'completed', + claimTxHash: spendTxId, + claimTxHex: signedSpendTxHex, + secret: inputSecret + } + + await fetch(`/api/swaps/${swap.id}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(updateData) + }) + + // Refresh swap data + fetchSwap() + alert(`HTLC claimed successfully! Spend TX ID: ${spendTxId}`) + return + } + + // Build spend HTLC parameters for creator (no secret needed) + const spendParams = { transaction_id: htlcTxHash } + + // Step 1: Sign the spend transaction (creator case) + const signedSpendTxHex = await client.spendHtlc(spendParams) + console.log('HTLC spend signed:', signedSpendTxHex) + + // Step 2: Broadcast the spend transaction + const broadcastResult = await client.broadcastTx(signedSpendTxHex) + console.log('HTLC spend broadcast result:', broadcastResult) + + const spendTxId = broadcastResult.tx_id || broadcastResult.transaction_id || broadcastResult.id + + // Step 3: Update swap status with both transaction ID and hex + // Note: We save the claim transaction hex because it's needed to extract the secret + const updateData = { + status: 'completed', + claimTxHash: spendTxId, + claimTxHex: signedSpendTxHex + } + + await fetch(`/api/swaps/${swap.id}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(updateData) + }) + + // Refresh swap data + fetchSwap() + alert(`HTLC claimed successfully! Spend TX ID: ${spendTxId}`) + } catch (error) { + console.error('Error claiming HTLC:', error) + alert('Failed to claim HTLC. Please try again.') + } finally { + setClaimingHtlc(false) + } + } + + const extractSecretFromClaim = async () => { + if (!client || !swap) { + alert('Missing required data for secret extraction') + return + } + + try { + // Check if there's a claim transaction to extract secret from + if (!swap.claimTxHex) { + alert('No claim transaction hex found') + return + } + + // Determine which HTLC transaction hex to use for extraction + // We need the original HTLC transaction hex that was claimed + const isUserCreator = swap.offer?.creatorMLAddress === userAddress + const originalHtlcTxHex = isUserCreator ? swap.takerHtlcTxHex : swap.creatorHtlcTxHex + + if (!originalHtlcTxHex) { + alert('Original HTLC transaction hex not found') + return + } + + // Extract secret using the claim transaction hex and original HTLC hex + const extractedSecret = await client.extractHtlcSecret({ + transaction_id: swap.claimTxHash || '', // We still need the transaction ID + transaction_hex: swap.claimTxHex, // Use the claim transaction hex + format: 'hex' + }) + + console.log('Extracted secret:', extractedSecret) + + // Update swap with extracted secret + await fetch(`/api/swaps/${swap.id}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + secret: extractedSecret + }) + }) + + // Refresh swap data + fetchSwap() + alert(`Secret extracted successfully: ${extractedSecret}`) + } catch (error) { + console.error('Error extracting secret:', error) + alert('Failed to extract secret. Please try again.') + } + } + + const claimWithExtractedSecret = async () => { + if (!client || !userAddress || !swap?.secret) { + alert('Missing required data for claiming with extracted secret') + return + } + + const isUserTaker = swap.takerMLAddress === userAddress + if (!isUserTaker) { + alert('Only the taker can use this function') + return + } + + if (!swap.creatorHtlcTxHash) { + alert('Creator HTLC not found') + return + } + + setClaimingHtlc(true) + try { + // Use the extracted secret to claim creator's HTLC + const spendParams = { + transaction_id: swap.creatorHtlcTxHash, + secret: swap.secret + } + + // Step 1: Sign the spend transaction + const signedSpendTxHex = await client.spendHtlc(spendParams) + console.log('Taker HTLC spend signed:', signedSpendTxHex) + + // Step 2: Broadcast the spend transaction + const broadcastResult = await client.broadcastTx(signedSpendTxHex) + console.log('Taker HTLC spend broadcast result:', broadcastResult) + + const spendTxId = broadcastResult.tx_id || broadcastResult.transaction_id || broadcastResult.id + + // Step 3: Update swap status to fully completed + await fetch(`/api/swaps/${swap.id}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + status: 'fully_completed' + // Note: We don't update claimTxHash here as it refers to the first claim + }) + }) + + // Refresh swap data + fetchSwap() + alert(`Successfully claimed creator's HTLC! TX ID: ${spendTxId}. Atomic swap completed!`) + } catch (error) { + console.error('Error claiming with extracted secret:', error) + alert('Failed to claim HTLC with extracted secret. Please try again.') + } finally { + setClaimingHtlc(false) + } + } + + const getStatusColor = (status: string) => { + switch (status) { + case 'pending': return 'text-yellow-600 bg-yellow-100' + case 'htlc_created': return 'text-blue-600 bg-blue-100' + case 'in_progress': return 'text-purple-600 bg-purple-100' + case 'completed': return 'text-green-600 bg-green-100' + case 'refunded': return 'text-red-600 bg-red-100' + default: return 'text-gray-600 bg-gray-100' + } + } + + const getStatusDescription = (status: string) => { + switch (status) { + case 'pending': return 'Waiting for HTLC creation' + case 'htlc_created': return 'HTLC created, waiting for counterparty' + case 'in_progress': return 'Both HTLCs created, ready to claim' + case 'completed': return 'Swap completed successfully' + case 'refunded': return 'Swap was refunded' + default: return 'Unknown status' + } + } + + const isUserCreator = swap?.offer?.creatorMLAddress === userAddress + const isUserTaker = swap?.takerMLAddress === userAddress + + if (loading) { + return ( +
+
Loading swap details...
+
+ ) + } + + if (!swap) { + return ( +
+
+

Swap Not Found

+ + Back to offers + +
+
+ ) + } + + return ( +
+
+
+

Swap #{swap.id}

+ {!userAddress && ( + + )} +
+
+ + {swap.status.toUpperCase()} + + {getStatusDescription(swap.status)} + {userAddress && ( + + Connected: {userAddress.slice(0, 10)}... + + )} +
+
+ +
+ {/* Swap Details */} +
+

Swap Details

+ +
+
+
+
+ {swap.offer?.amountA} {getTokenSymbol(swap.offer?.tokenA || '')} +
+
From Creator
+
+ + + +
+
+ {swap.offer?.amountB} {getTokenSymbol(swap.offer?.tokenB || '')} +
+
To Taker
+
+
+ +
+
+
+ Creator: +
+ {swap.offer?.creatorMLAddress} +
+
+
+ Taker: +
+ {swap.takerMLAddress} +
+
+
+
+ +
+
+
Created: {new Date(swap.createdAt).toLocaleString()}
+ {swap.offer?.contact && ( +
Contact: {swap.offer.contact}
+ )} +
+
+
+
+ + {/* Progress Steps */} +
+

Progress

+ +
+
+
+ Offer accepted +
+ +
+
+ Creator HTLC created +
+ +
+
+ Taker HTLC created +
+ +
+
+ First HTLC claimed (secret revealed) +
+ +
+
+ Secret extracted +
+ +
+
+ Second HTLC claimed (swap complete) +
+
+
+
+ + {/* Action Section */} +
+

Next Steps

+ + {swap.status === 'pending' && ( +
+

+ {isUserCreator + ? "You need to create the initial HTLC to start the swap process." + : "Waiting for the creator to create the initial HTLC." + } +

+ {isUserCreator && ( +
+ {!secretHash ? ( +
+

+ Step 1: Generate a secret hash for the HTLC +

+ +
+ ) : ( +
+

+ โœ… Secret hash generated successfully +

+
+ {JSON.stringify(secretHash, null, 2)} +
+

+ Step 2: Create the HTLC contract +

+ +
+ )} +
+ )} +
+ )} + + {swap.status === 'htlc_created' && ( +
+

+ {isUserTaker + ? "The creator has created their HTLC. You need to create your counterparty HTLC." + : "You've created your HTLC. Waiting for the taker to create their counterparty HTLC." + } +

+ {isUserTaker && ( +
+
+

Creator's HTLC Details:

+
+
Amount: {swap.offer?.amountA} {getTokenSymbol(swap.offer?.tokenA || '')}
+
Secret Hash: {swap.secretHash ? JSON.parse(swap.secretHash).secret_hash_hex.slice(0, 20) + '...' : 'N/A'}
+ {swap.creatorHtlcTxHash && ( +
TX ID: {swap.creatorHtlcTxHash.slice(0, 20)}...
+ )} +
+
+
+

+ Create your counterparty HTLC with {swap.offer?.amountB} {getTokenSymbol(swap.offer?.tokenB || '')} +

+ +
+
+ )} +
+ )} + + {swap.status === 'in_progress' && ( +
+

+ Both HTLCs are created. You can now claim your tokens. Claiming will reveal the secret and allow the other party to claim their tokens. +

+
+
+

Available to Claim:

+
+ {isUserCreator && ( +
+ You can claim: {swap.offer?.amountB} {getTokenSymbol(swap.offer?.tokenB || '')} from taker's HTLC +
+ โœ“ Your wallet has the secret +
+ )} + {isUserTaker && ( +
+ You can claim: {swap.offer?.amountA} {getTokenSymbol(swap.offer?.tokenA || '')} from creator's HTLC +
+ โš  You need to provide the secret +
+ )} +
+
+ +
+
+ )} + + {(swap.status === 'completed' || swap.status === 'fully_completed') && ( +
+ {swap.claimTxHash && ( +
+

+ {swap.status === 'fully_completed' + ? "๐ŸŽ‰ Atomic swap completed successfully! Both parties have their tokens." + : isUserCreator + ? "You have claimed the taker's HTLC!" + : "The creator has claimed your HTLC!" + } +

+

+ Claim Transaction: {swap.claimTxHash} +

+ + {swap.secret ? ( +
+
+

Revealed Secret:

+

{swap.secret}

+
+ + {isUserTaker && ( +
+

+ Now you can claim the creator's HTLC using this secret: +

+ +
+ )} + + {isUserCreator && ( +

+ โœ… Atomic swap completed! Both parties have their tokens. +

+ )} +
+ ) : ( +
+

+ {isUserTaker ? "Extract the secret to claim the creator's HTLC:" : "The taker needs to extract the secret:"} +

+ +
+ )} +
+ )} +
+ )} + + {swap.status === 'refunded' && ( +
+

+ The swap was manually refunded after the timelock expired. +

+
+ )} + + {(swap.status === 'htlc_created' || swap.status === 'in_progress') && ( +
+

+ Timelock Protection: If the swap is not completed within the timelock period, + you can manually refund your HTLC to recover your tokens. +

+ +
+ )} +
+
+ ) +} diff --git a/examples/swap-board-ml-btc/src/lib/htlc-utils.ts b/examples/swap-board-ml-btc/src/lib/htlc-utils.ts new file mode 100644 index 0000000..9165465 --- /dev/null +++ b/examples/swap-board-ml-btc/src/lib/htlc-utils.ts @@ -0,0 +1,168 @@ +import { Client } from '@mintlayer/sdk' + +export interface HTLCParams { + amount: string + token_id?: string | null + secret_hash: any + spend_address: string + refund_address: string + refund_timelock: { + type: 'ForBlockCount' | 'UntilTime' + content: number | string + } +} + +export interface SecretHashResponse { + secret: string + secret_hash_hex: string + secret_hash: { + hex: string + string?: string | null + } +} + +/** + * Generate a secret hash using the wallet + */ +export async function generateSecretHash(client: Client): Promise { + return await client.requestSecretHash({}) +} + +/** + * Create an HTLC with the given parameters and broadcast it + */ +export async function createHTLC(client: Client, params: HTLCParams): Promise<{ txHex: string, txId: string }> { + // Sign the transaction + const signedTxHex = await client.createHtlc(params) + + // Broadcast to network + const broadcastResult = await client.broadcastTx(signedTxHex) + const txId = broadcastResult.tx_id || broadcastResult.transaction_id || broadcastResult.id + + return { txHex: signedTxHex, txId } +} + +/** + * Build HTLC parameters for a swap offer (creator's HTLC) + */ +export function buildHTLCParams( + offer: any, + secretHash: any, + spendAddress: string, + refundAddress: string, + timelockBlocks: number = 144 // ~24 hours +): HTLCParams { + return { + amount: offer.amountA, + token_id: offer.tokenA === 'ML' ? null : offer.tokenA, + secret_hash: { hex: secretHash.secret_hash_hex }, + spend_address: spendAddress, + refund_address: refundAddress, + refund_timelock: { + type: 'ForBlockCount', + content: timelockBlocks + } + } +} + +/** + * Build counterparty HTLC parameters (taker's HTLC) + */ +export function buildCounterpartyHTLCParams( + offer: any, + secretHashHex: string, + spendAddress: string, + refundAddress: string, + timelockBlocks: number = 144 // ~24 hours +): HTLCParams { + return { + amount: offer.amountB, // Taker gives amountB + token_id: offer.tokenB === 'ML' ? null : offer.tokenB, + secret_hash: { hex: secretHashHex }, // Use same secret hash as creator + spend_address: spendAddress, // Creator can spend with secret + refund_address: refundAddress, // Taker can refund after timelock + refund_timelock: { + type: 'ForBlockCount', + content: timelockBlocks + } + } +} + +/** + * Extract secret from a completed HTLC claim transaction + * @param client - Mintlayer client + * @param claimTransactionId - The transaction ID that claimed/spent the HTLC + * @param claimTransactionHex - The signed claim transaction hex (contains the secret) + * @returns The extracted secret in hex format + */ +export async function extractHTLCSecret( + client: Client, + claimTransactionId: string, + claimTransactionHex: string +): Promise { + return await client.extractHtlcSecret({ + transaction_id: claimTransactionId, + transaction_hex: claimTransactionHex, + format: 'hex' + }) +} + +/** + * Create and broadcast an HTLC, returning both transaction ID and hex + */ +export async function createAndBroadcastHTLC( + client: Client, + params: HTLCParams +): Promise<{ txId: string, txHex: string }> { + // Step 1: Sign the transaction + const signedTxHex = await client.createHtlc(params) + + // Step 2: Broadcast to network + const broadcastResult = await client.broadcastTx(signedTxHex) + const txId = broadcastResult.tx_id || broadcastResult.transaction_id || broadcastResult.id + + return { txId, txHex: signedTxHex } +} + +/** + * Claim/spend an HTLC + * @param client - Mintlayer client + * @param htlcTxHash - Hash of the HTLC transaction to spend + * @param secret - Secret to reveal (optional if wallet has it) + */ +export async function claimHTLC( + client: Client, + htlcTxHash: string, + secret?: string +): Promise<{ txId: string, txHex: string }> { + // Step 1: Sign the spend transaction + const spendParams: any = { transaction_id: htlcTxHash } + if (secret) { + spendParams.secret = secret + } + + const signedTxHex = await client.spendHtlc(spendParams) + + // Step 2: Broadcast to network + const broadcastResult = await client.broadcastTx(signedTxHex) + const txId = broadcastResult.tx_id || broadcastResult.transaction_id || broadcastResult.id + + return { txId, txHex: signedTxHex } +} + +/** + * Get timelock expiry information + */ +export function getTimelockInfo(blocks: number): { + estimatedHours: number + estimatedExpiry: Date +} { + const estimatedMinutes = blocks * 10 // Assuming 10min blocks + const estimatedHours = estimatedMinutes / 60 + const estimatedExpiry = new Date(Date.now() + estimatedMinutes * 60 * 1000) + + return { + estimatedHours, + estimatedExpiry + } +} diff --git a/examples/swap-board-ml-btc/src/lib/prisma.ts b/examples/swap-board-ml-btc/src/lib/prisma.ts new file mode 100644 index 0000000..af2a01e --- /dev/null +++ b/examples/swap-board-ml-btc/src/lib/prisma.ts @@ -0,0 +1,9 @@ +import { PrismaClient } from '@prisma/client' + +const globalForPrisma = globalThis as unknown as { + prisma: PrismaClient | undefined +} + +export const prisma = globalForPrisma.prisma ?? new PrismaClient() + +if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma diff --git a/examples/swap-board-ml-btc/src/types/swap.ts b/examples/swap-board-ml-btc/src/types/swap.ts new file mode 100644 index 0000000..98a0b82 --- /dev/null +++ b/examples/swap-board-ml-btc/src/types/swap.ts @@ -0,0 +1,56 @@ +export interface Offer { + id: number + direction: string + tokenA: string + tokenB: string + amountA: string + amountB: string + price: number + creatorMLAddress: string + contact?: string + status: 'open' | 'taken' | 'completed' | 'cancelled' + createdAt: Date +} + +export interface Swap { + id: number + offerId: number + takerMLAddress: string + status: 'pending' | 'htlc_created' | 'in_progress' | 'completed' | 'fully_completed' | 'refunded' + secretHash?: string + secret?: string + creatorHtlcTxHash?: string + creatorHtlcTxHex?: string + takerHtlcTxHash?: string + takerHtlcTxHex?: string + claimTxHash?: string + claimTxHex?: string + createdAt: Date + offer?: Offer +} + +export interface CreateOfferRequest { + tokenA: string + tokenB: string + amountA: string + amountB: string + creatorMLAddress: string + contact?: string +} + +export interface AcceptOfferRequest { + offerId: number + takerMLAddress: string +} + +export interface UpdateSwapRequest { + status?: Swap['status'] + secretHash?: string + secret?: string + creatorHtlcTxHash?: string + creatorHtlcTxHex?: string + takerHtlcTxHash?: string + takerHtlcTxHex?: string + claimTxHash?: string + claimTxHex?: string +} diff --git a/examples/swap-board-ml-btc/tailwind.config.js b/examples/swap-board-ml-btc/tailwind.config.js new file mode 100644 index 0000000..3660a2b --- /dev/null +++ b/examples/swap-board-ml-btc/tailwind.config.js @@ -0,0 +1,27 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: [ + './src/pages/**/*.{js,ts,jsx,tsx,mdx}', + './src/components/**/*.{js,ts,jsx,tsx,mdx}', + './src/app/**/*.{js,ts,jsx,tsx,mdx}', + ], + theme: { + extend: { + colors: { + mintlayer: { + 50: '#f0f9ff', + 100: '#e0f2fe', + 200: '#bae6fd', + 300: '#7dd3fc', + 400: '#38bdf8', + 500: '#0ea5e9', + 600: '#0284c7', + 700: '#0369a1', + 800: '#075985', + 900: '#0c4a6e', + }, + }, + }, + }, + plugins: [], +} diff --git a/examples/swap-board-ml-btc/tsconfig.json b/examples/swap-board-ml-btc/tsconfig.json new file mode 100644 index 0000000..abb59dc --- /dev/null +++ b/examples/swap-board-ml-btc/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "target": "es5", + "lib": ["dom", "dom.iterable", "es6"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} From 12cf0b927f3a15d199457ba2b63b43362b646042 Mon Sep 17 00:00:00 2001 From: Sergey Chystiakov Date: Thu, 17 Jul 2025 17:38:11 +0200 Subject: [PATCH 2/5] add support for btc --- examples/swap-board-ml-btc/BTC_INTEGRATION.md | 191 +++++++++ .../swap-board-ml-btc/prisma/schema.prisma | 47 ++- .../src/app/api/offers/route.ts | 37 +- .../src/app/api/swaps/[id]/route.ts | 34 +- .../src/app/api/swaps/route.ts | 39 +- .../swap-board-ml-btc/src/app/create/page.tsx | 27 ++ .../swap-board-ml-btc/src/app/offers/page.tsx | 25 ++ .../src/app/swap/[id]/page.tsx | 363 +++++++++++++++++- .../src/lib/btc-request-builder.ts | 226 +++++++++++ .../swap-board-ml-btc/src/types/btc-wallet.ts | 63 +++ examples/swap-board-ml-btc/src/types/swap.ts | 50 ++- .../swap-board-ml-btc/test-btc-integration.js | 169 ++++++++ 12 files changed, 1215 insertions(+), 56 deletions(-) create mode 100644 examples/swap-board-ml-btc/BTC_INTEGRATION.md create mode 100644 examples/swap-board-ml-btc/src/lib/btc-request-builder.ts create mode 100644 examples/swap-board-ml-btc/src/types/btc-wallet.ts create mode 100644 examples/swap-board-ml-btc/test-btc-integration.js diff --git a/examples/swap-board-ml-btc/BTC_INTEGRATION.md b/examples/swap-board-ml-btc/BTC_INTEGRATION.md new file mode 100644 index 0000000..02fcd76 --- /dev/null +++ b/examples/swap-board-ml-btc/BTC_INTEGRATION.md @@ -0,0 +1,191 @@ +# BTC Integration for Mintlayer P2P Swap Board + +This document describes the Bitcoin (BTC) integration added to the swap board, enabling atomic swaps between Mintlayer tokens and native Bitcoin. + +## Overview + +The BTC integration allows users to: +- Create offers involving BTC (BTC โ†’ ML tokens or ML tokens โ†’ BTC) +- Accept BTC offers by providing BTC credentials +- Create BTC HTLCs for atomic swaps +- Claim and refund BTC HTLCs +- Track BTC transactions on blockchain explorers + +## Architecture + +### Wallet-Centric Design +- **Web App**: Builds requests and manages UI/coordination +- **Wallet Extension**: Handles all BTC cryptographic operations +- **No Private Keys**: Web app never handles BTC private keys + +### Key Components + +#### 1. Database Schema (`prisma/schema.prisma`) +**Offer Model Additions:** +- `creatorBTCAddress`: Creator's BTC address +- `creatorBTCPublicKey`: Creator's BTC public key for HTLC creation + +**Swap Model Additions:** +- `takerBTCAddress`: Taker's BTC address +- `takerBTCPublicKey`: Taker's BTC public key for HTLC creation +- `btcHtlcAddress`: Generated BTC HTLC contract address +- `btcRedeemScript`: BTC HTLC redeem script +- `btcHtlcTxId`: BTC HTLC funding transaction ID +- `btcHtlcTxHex`: BTC HTLC signed transaction hex +- `btcClaimTxId`: BTC claim transaction ID +- `btcClaimTxHex`: BTC claim signed transaction hex +- `btcRefundTxId`: BTC refund transaction ID +- `btcRefundTxHex`: BTC refund signed transaction hex + +#### 2. Type Definitions (`src/types/`) +- `btc-wallet.ts`: Wallet interface definitions +- `swap.ts`: Updated with BTC fields and new status types + +#### 3. BTC Utilities (`src/lib/btc-request-builder.ts`) +- Amount conversion (BTC โ†” satoshis) +- Address and public key validation +- HTLC request builders +- Explorer URL generators + +#### 4. API Endpoints +- **POST /api/offers**: Validates BTC credentials for BTC offers +- **POST /api/swaps**: Handles BTC credentials during offer acceptance +- **POST /api/swaps/[id]**: Updates swaps with BTC transaction data + +#### 5. Frontend Components +- **Create Offer**: Requests BTC credentials when BTC is involved +- **Offers List**: Handles BTC credential exchange during acceptance +- **Swap Detail**: Full BTC HTLC management interface + +## Wallet Integration Requirements + +The wallet extension must implement these methods: + +```typescript +interface BTCWalletMethods { + // Get user's BTC receiving address + getBTCAddress(): Promise + + // Get user's BTC public key for HTLC creation + getBTCPublicKey(): Promise + + // Create BTC HTLC transaction + createBTCHTLC(request: BTCHTLCCreateRequest): Promise + + // Spend/claim BTC HTLC + spendBTCHTLC(request: BTCHTLCSpendRequest): Promise + + // Refund BTC HTLC after timeout + refundBTCHTLC(request: BTCHTLCRefundRequest): Promise + + // Broadcast BTC transaction to network + broadcastBTCTransaction(txHex: string): Promise +} +``` + +### Request/Response Types + +```typescript +interface BTCHTLCCreateRequest { + amount: string // in satoshis + secretHash: string // hex format + recipientPublicKey: string // who can claim with secret + refundPublicKey: string // who can refund after timeout + timeoutBlocks: number // BTC blocks +} + +interface BTCHTLCCreateResponse { + signedTxHex: string // ready to broadcast + transactionId: string // transaction ID + htlcAddress: string // HTLC contract address + redeemScript: string // for spending operations +} +``` + +## Swap Flow + +### ML โ†’ BTC Swap +1. **Creator creates offer**: Provides BTC address + public key +2. **Taker accepts**: Provides their BTC address + public key +3. **Creator creates ML HTLC**: Standard Mintlayer HTLC +4. **Taker creates BTC HTLC**: Using creator's public key as recipient +5. **Creator claims BTC**: Uses secret to spend BTC HTLC +6. **Taker claims ML**: Uses revealed secret to claim ML HTLC + +### BTC โ†’ ML Swap +1. **Creator creates offer**: Provides BTC address + public key +2. **Taker accepts**: Provides their BTC address + public key +3. **Creator creates BTC HTLC**: Using taker's public key as recipient +4. **Taker creates ML HTLC**: Standard Mintlayer HTLC +5. **Taker claims BTC**: Uses secret to spend BTC HTLC +6. **Creator claims ML**: Uses revealed secret to claim ML HTLC + +## Status Tracking + +New swap statuses: +- `btc_htlc_created`: BTC HTLC has been created +- `both_htlcs_created`: Both ML and BTC HTLCs exist +- `btc_refunded`: BTC side was refunded + +## Security Considerations + +### Public Key Exchange +- Public keys are required for HTLC script generation +- Keys are stored in database (consider privacy implications) +- Keys are visible to counterparty (required for HTLC creation) + +### Timelock Coordination +- BTC timelock should be shorter than ML timelock +- Ensures proper claim ordering for security + +### Atomic Guarantees +- Same secret hash used for both chains +- Standard HTLC atomic swap properties maintained +- Manual refund available after timelock expiry + +## Testing + +Run the integration test: +```bash +node test-btc-integration.js +``` + +## Development Status + +### โœ… Completed +- Database schema with BTC fields +- Type definitions and interfaces +- BTC utility functions +- API endpoint updates +- Frontend BTC integration +- Status tracking and UI + +### ๐Ÿ”„ Pending (Wallet Extension) +- BTC wallet method implementations +- BTC HTLC script generation +- BTC transaction building and signing +- BTC network integration +- Secret extraction from BTC claims + +### ๐Ÿงช Testing Needed +- End-to-end BTC swap testing +- Error handling and edge cases +- Network compatibility (testnet/mainnet) +- Performance optimization + +## Next Steps + +1. **Implement wallet BTC methods** in browser extension +2. **Test with actual BTC transactions** on testnet +3. **Add comprehensive error handling** +4. **Optimize user experience** and add loading states +5. **Add transaction monitoring** and confirmation tracking +6. **Security audit** of BTC integration + +## Support + +For questions about the BTC integration: +- Check the test file for usage examples +- Review the utility functions in `btc-request-builder.ts` +- Examine the swap detail page for UI implementation +- Test with the provided mock data structures diff --git a/examples/swap-board-ml-btc/prisma/schema.prisma b/examples/swap-board-ml-btc/prisma/schema.prisma index b722594..0305c9b 100644 --- a/examples/swap-board-ml-btc/prisma/schema.prisma +++ b/examples/swap-board-ml-btc/prisma/schema.prisma @@ -11,34 +11,53 @@ datasource db { } model Offer { - id Int @id @default(autoincrement()) - direction String // "tokenA->tokenB" - tokenA String - tokenB String - amountA String - amountB String - price Float - creatorMLAddress String - contact String? - status String @default("open") // open, taken, completed, cancelled - createdAt DateTime @default(now()) - - swaps Swap[] + id Int @id @default(autoincrement()) + direction String // "tokenA->tokenB" + tokenA String + tokenB String + amountA String + amountB String + price Float + creatorMLAddress String + creatorBTCAddress String? // Creator's BTC address (when offering BTC) + creatorBTCPublicKey String? // Creator's BTC public key (when offering BTC) + contact String? + status String @default("open") // open, taken, completed, cancelled + createdAt DateTime @default(now()) + + swaps Swap[] } model Swap { id Int @id @default(autoincrement()) offerId Int takerMLAddress String - status String @default("pending") // pending, htlc_created, in_progress, completed, fully_completed, refunded + takerBTCAddress String? // Taker's BTC address (when accepting BTC offer) + takerBTCPublicKey String? // Taker's BTC public key (when accepting BTC offer) + status String @default("pending") // pending, htlc_created, btc_htlc_created, both_htlcs_created, in_progress, completed, fully_completed, refunded, btc_refunded secretHash String? secret String? + + // Mintlayer HTLC fields creatorHtlcTxHash String? // Creator's HTLC transaction ID creatorHtlcTxHex String? // Creator's signed HTLC transaction hex takerHtlcTxHash String? // Taker's HTLC transaction ID takerHtlcTxHex String? // Taker's signed HTLC transaction hex claimTxHash String? // Final claim transaction ID claimTxHex String? // Final claim transaction hex (needed for secret extraction) + + // BTC HTLC contract details + btcHtlcAddress String? // Generated BTC HTLC contract address + btcRedeemScript String? // BTC HTLC redeem script + + // BTC transaction tracking + btcHtlcTxId String? // BTC HTLC funding transaction ID + btcHtlcTxHex String? // BTC HTLC signed transaction hex + btcClaimTxId String? // BTC claim transaction ID + btcClaimTxHex String? // BTC claim signed transaction hex + btcRefundTxId String? // BTC refund transaction ID + btcRefundTxHex String? // BTC refund signed transaction hex + createdAt DateTime @default(now()) offer Offer @relation(fields: [offerId], references: [id]) diff --git a/examples/swap-board-ml-btc/src/app/api/offers/route.ts b/examples/swap-board-ml-btc/src/app/api/offers/route.ts index 56eac3f..80d240a 100644 --- a/examples/swap-board-ml-btc/src/app/api/offers/route.ts +++ b/examples/swap-board-ml-btc/src/app/api/offers/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from 'next/server' import { prisma } from '@/lib/prisma' import { CreateOfferRequest } from '@/types/swap' +import { isValidBTCAddress, isValidBTCPublicKey } from '@/lib/btc-request-builder' export async function GET() { try { @@ -12,7 +13,7 @@ export async function GET() { createdAt: 'desc' } }) - + return NextResponse.json(offers) } catch (error) { console.error('Error fetching offers:', error) @@ -26,7 +27,7 @@ export async function GET() { export async function POST(request: NextRequest) { try { const body: CreateOfferRequest = await request.json() - + // Validate required fields if (!body.tokenA || !body.tokenB || !body.amountA || !body.amountB || !body.creatorMLAddress) { return NextResponse.json( @@ -34,10 +35,34 @@ export async function POST(request: NextRequest) { { status: 400 } ) } - + + // Validate BTC fields if BTC is involved + if (body.tokenA === 'BTC' || body.tokenB === 'BTC') { + if (!body.creatorBTCAddress || !body.creatorBTCPublicKey) { + return NextResponse.json( + { error: 'BTC address and public key required for BTC offers' }, + { status: 400 } + ) + } + + if (!isValidBTCAddress(body.creatorBTCAddress)) { + return NextResponse.json( + { error: 'Invalid BTC address format' }, + { status: 400 } + ) + } + + if (!isValidBTCPublicKey(body.creatorBTCPublicKey)) { + return NextResponse.json( + { error: 'Invalid BTC public key format' }, + { status: 400 } + ) + } + } + // Calculate price (amountB / amountA) const price = parseFloat(body.amountB) / parseFloat(body.amountA) - + const offer = await prisma.offer.create({ data: { direction: `${body.tokenA}->${body.tokenB}`, @@ -47,11 +72,13 @@ export async function POST(request: NextRequest) { amountB: body.amountB, price: price, creatorMLAddress: body.creatorMLAddress, + creatorBTCAddress: body.creatorBTCAddress || null, + creatorBTCPublicKey: body.creatorBTCPublicKey || null, contact: body.contact || null, status: 'open' } }) - + return NextResponse.json(offer, { status: 201 }) } catch (error) { console.error('Error creating offer:', error) diff --git a/examples/swap-board-ml-btc/src/app/api/swaps/[id]/route.ts b/examples/swap-board-ml-btc/src/app/api/swaps/[id]/route.ts index 363d63b..e82da03 100644 --- a/examples/swap-board-ml-btc/src/app/api/swaps/[id]/route.ts +++ b/examples/swap-board-ml-btc/src/app/api/swaps/[id]/route.ts @@ -8,28 +8,28 @@ export async function GET( ) { try { const swapId = parseInt(params.id) - + if (isNaN(swapId)) { return NextResponse.json( { error: 'Invalid swap ID' }, { status: 400 } ) } - + const swap = await prisma.swap.findUnique({ where: { id: swapId }, include: { offer: true } }) - + if (!swap) { return NextResponse.json( { error: 'Swap not found' }, { status: 404 } ) } - + return NextResponse.json(swap) } catch (error) { console.error('Error fetching swap:', error) @@ -47,38 +47,50 @@ export async function POST( try { const swapId = parseInt(params.id) const body: UpdateSwapRequest = await request.json() - + if (isNaN(swapId)) { return NextResponse.json( { error: 'Invalid swap ID' }, { status: 400 } ) } - + // Check if swap exists const existingSwap = await prisma.swap.findUnique({ where: { id: swapId } }) - + if (!existingSwap) { return NextResponse.json( { error: 'Swap not found' }, { status: 404 } ) } - + // Update swap with provided fields const updateData: any = {} if (body.status) updateData.status = body.status if (body.secretHash) updateData.secretHash = body.secretHash if (body.secret) updateData.secret = body.secret + + // Mintlayer HTLC updates if (body.creatorHtlcTxHash) updateData.creatorHtlcTxHash = body.creatorHtlcTxHash if (body.creatorHtlcTxHex) updateData.creatorHtlcTxHex = body.creatorHtlcTxHex if (body.takerHtlcTxHash) updateData.takerHtlcTxHash = body.takerHtlcTxHash if (body.takerHtlcTxHex) updateData.takerHtlcTxHex = body.takerHtlcTxHex if (body.claimTxHash) updateData.claimTxHash = body.claimTxHash if (body.claimTxHex) updateData.claimTxHex = body.claimTxHex - + + // BTC HTLC updates + if (body.btcHtlcAddress) updateData.btcHtlcAddress = body.btcHtlcAddress + if (body.btcRedeemScript) updateData.btcRedeemScript = body.btcRedeemScript + if (body.btcHtlcTxId) updateData.btcHtlcTxId = body.btcHtlcTxId + if (body.btcHtlcTxHex) updateData.btcHtlcTxHex = body.btcHtlcTxHex + if (body.btcClaimTxId) updateData.btcClaimTxId = body.btcClaimTxId + if (body.btcClaimTxHex) updateData.btcClaimTxHex = body.btcClaimTxHex + if (body.btcRefundTxId) updateData.btcRefundTxId = body.btcRefundTxId + if (body.btcRefundTxHex) updateData.btcRefundTxHex = body.btcRefundTxHex + const updatedSwap = await prisma.swap.update({ where: { id: swapId }, data: updateData, @@ -86,7 +98,7 @@ export async function POST( offer: true } }) - + // If swap is completed, update offer status if (body.status === 'completed') { await prisma.offer.update({ @@ -94,7 +106,7 @@ export async function POST( data: { status: 'completed' } }) } - + return NextResponse.json(updatedSwap) } catch (error) { console.error('Error updating swap:', error) diff --git a/examples/swap-board-ml-btc/src/app/api/swaps/route.ts b/examples/swap-board-ml-btc/src/app/api/swaps/route.ts index 6ace226..2c2cfc2 100644 --- a/examples/swap-board-ml-btc/src/app/api/swaps/route.ts +++ b/examples/swap-board-ml-btc/src/app/api/swaps/route.ts @@ -1,11 +1,12 @@ import { NextRequest, NextResponse } from 'next/server' import { prisma } from '@/lib/prisma' import { AcceptOfferRequest } from '@/types/swap' +import { isValidBTCAddress, isValidBTCPublicKey } from '@/lib/btc-request-builder' export async function POST(request: NextRequest) { try { const body: AcceptOfferRequest = await request.json() - + // Validate required fields if (!body.offerId || !body.takerMLAddress) { return NextResponse.json( @@ -13,32 +14,58 @@ export async function POST(request: NextRequest) { { status: 400 } ) } - + // Check if offer exists and is open const offer = await prisma.offer.findUnique({ where: { id: body.offerId } }) - + if (!offer) { return NextResponse.json( { error: 'Offer not found' }, { status: 404 } ) } - + if (offer.status !== 'open') { return NextResponse.json( { error: 'Offer is no longer available' }, { status: 400 } ) } - + + // Validate BTC fields if BTC is involved + if (offer.tokenA === 'BTC' || offer.tokenB === 'BTC') { + if (!body.takerBTCAddress || !body.takerBTCPublicKey) { + return NextResponse.json( + { error: 'BTC address and public key required for BTC swaps' }, + { status: 400 } + ) + } + + if (!isValidBTCAddress(body.takerBTCAddress)) { + return NextResponse.json( + { error: 'Invalid BTC address format' }, + { status: 400 } + ) + } + + if (!isValidBTCPublicKey(body.takerBTCPublicKey)) { + return NextResponse.json( + { error: 'Invalid BTC public key format' }, + { status: 400 } + ) + } + } + // Create swap and update offer status const [swap] = await prisma.$transaction([ prisma.swap.create({ data: { offerId: body.offerId, takerMLAddress: body.takerMLAddress, + takerBTCAddress: body.takerBTCAddress || null, + takerBTCPublicKey: body.takerBTCPublicKey || null, status: 'pending' }, include: { @@ -50,7 +77,7 @@ export async function POST(request: NextRequest) { data: { status: 'taken' } }) ]) - + return NextResponse.json(swap, { status: 201 }) } catch (error) { console.error('Error accepting offer:', error) diff --git a/examples/swap-board-ml-btc/src/app/create/page.tsx b/examples/swap-board-ml-btc/src/app/create/page.tsx index 673bbe8..2b27b02 100644 --- a/examples/swap-board-ml-btc/src/app/create/page.tsx +++ b/examples/swap-board-ml-btc/src/app/create/page.tsx @@ -91,6 +91,9 @@ export default function CreateOfferPage() { } const getSelectedToken = (tokenId: string) => { + if (tokenId === 'BTC') { + return { token_id: 'BTC', symbol: 'BTC', number_of_decimals: 8 } + } return tokens.find(t => t.token_id === tokenId) } @@ -117,6 +120,26 @@ export default function CreateOfferPage() { setLoading(true) try { + let creatorBTCAddress, creatorBTCPublicKey; + + // If offering BTC or requesting BTC, get BTC credentials + if (formData.tokenA === 'BTC' || formData.tokenB === 'BTC') { + if (!client) { + alert('Wallet client not initialized') + return + } + + try { + // Get BTC credentials from wallet + creatorBTCAddress = await (client as any).getBTCAddress() + creatorBTCPublicKey = await (client as any).getBTCPublicKey() + } catch (error) { + console.error('Error getting BTC credentials:', error) + alert('Failed to get BTC credentials from wallet. Please make sure your wallet supports BTC.') + return + } + } + const response = await fetch('/api/offers', { method: 'POST', headers: { @@ -125,6 +148,8 @@ export default function CreateOfferPage() { body: JSON.stringify({ ...formData, creatorMLAddress: userAddress, + creatorBTCAddress, + creatorBTCPublicKey, }), }) @@ -177,6 +202,7 @@ export default function CreateOfferPage() { required > + {tokens.map((token) => ( + {tokens.map((token) => (