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/BTC_INTEGRATION.md b/examples/swap-board-ml-btc/BTC_INTEGRATION.md new file mode 100644 index 0000000..9b3d624 --- /dev/null +++ b/examples/swap-board-ml-btc/BTC_INTEGRATION.md @@ -0,0 +1,135 @@ +# 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 + +## 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 +- 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 + +## 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/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..e54aeba --- /dev/null +++ b/examples/swap-board-ml-btc/next.config.js @@ -0,0 +1,16 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + webpack: (config) => { + config.experiments = { + ...config.experiments, + asyncWebAssembly: true, + }; + return config; + }, + // Disable static generation for pages that might use browser APIs + experimental: { + missingSuspenseWithCSRBailout: false, + } +} + +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..0305c9b --- /dev/null +++ b/examples/swap-board-ml-btc/prisma/schema.prisma @@ -0,0 +1,64 @@ +// 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 + 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 + 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 new file mode 100644 index 0000000..80d240a --- /dev/null +++ b/examples/swap-board-ml-btc/src/app/api/offers/route.ts @@ -0,0 +1,90 @@ +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 { + 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 } + ) + } + + // 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}`, + tokenA: body.tokenA, + tokenB: body.tokenB, + amountA: body.amountA, + 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) + 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..e82da03 --- /dev/null +++ b/examples/swap-board-ml-btc/src/app/api/swaps/[id]/route.ts @@ -0,0 +1,118 @@ +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 + + // 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, + 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..f27fbeb --- /dev/null +++ b/examples/swap-board-ml-btc/src/app/api/swaps/route.ts @@ -0,0 +1,90 @@ +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( + { 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 } + ) + } + + // 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, + // @ts-ignore + takerBTCAddress: body.takerBTCAddress || null, + takerBTCPublicKey: body.takerBTCPublicKey || null, + 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..b8698bf --- /dev/null +++ b/examples/swap-board-ml-btc/src/app/create/page.tsx @@ -0,0 +1,353 @@ +'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 { + // Only initialize client on the client side + if (typeof window === 'undefined') return + + 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) => { + if (tokenId === 'BTC') { + return { token_id: 'BTC', symbol: 'BTC', number_of_decimals: 8 } + } + 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 { + 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 + const BTCData = await (client as any).request({ method: 'getData', params: { items: ['btcAddress', 'btcPublicKey']} }) + + creatorBTCAddress = BTCData.btcAddress + creatorBTCPublicKey = BTCData.btcPublicKey + } 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: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + ...formData, + creatorMLAddress: userAddress, + creatorBTCAddress, + creatorBTCPublicKey, + }), + }) + + 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..273ee51 --- /dev/null +++ b/examples/swap-board-ml-btc/src/app/offers/page.tsx @@ -0,0 +1,241 @@ +'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 { + // Only initialize client on the client side + if (typeof window === 'undefined') return + + 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' + if (tokenId === 'BTC') return 'BTC' + 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 { + // Find the offer to check if BTC is involved + const offer = offers.find(o => o.id === offerId) + let takerBTCAddress, takerBTCPublicKey; + + // If offer involves BTC, get BTC credentials + if (offer && (offer.tokenA === 'BTC' || offer.tokenB === 'BTC')) { + if (!client) { + alert('Wallet client not initialized') + return + } + + try { + // Get BTC credentials from wallet + const BTCData = await (client as any).request({ method: 'getData', params: { items: ['btcAddress', 'btcPublicKey']} }) + + takerBTCAddress = BTCData.btcAddress + takerBTCPublicKey = BTCData.btcPublicKey + } 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/swaps', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + offerId, + takerMLAddress: userAddress, + takerBTCAddress, + takerBTCPublicKey, + }), + }) + + 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..7b670c0 --- /dev/null +++ b/examples/swap-board-ml-btc/src/app/swap/[id]/page.tsx @@ -0,0 +1,1326 @@ +'use client' + +import { useState, useEffect } from 'react' +import { Client } from '@mintlayer/sdk' +import { Swap } from '@/types/swap' +import { + buildCreatorBTCHTLCRequest, + buildTakerBTCHTLCRequest, + buildBTCHTLCSpendRequest, + buildBTCHTLCRefundRequest, + validateSwapForBTCHTLC, + offerInvolvesBTC, + isCreatorOfferingBTC, + getBTCExplorerURL, + getBTCAddressExplorerURL, + broadcastBTCTransaction, + findHTLCUTXO, + extractSecretFromBTCTransaction +} from '@/lib/btc-request-builder' + +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([]) + + // BTC-related state + const [userBTCAddress, setUserBTCAddress] = useState('') + const [creatingBTCHtlc, setCreatingBTCHtlc] = useState(false) + const [claimingBTCHtlc, setClaimingBTCHtlc] = useState(false) + const [refundingBTCHtlc, setRefundingBTCHtlc] = useState(false) + + useEffect(() => { + fetchSwap() + initializeClient() + fetchTokens() + + // Poll for updates every 10 seconds + const interval = setInterval(fetchSwap, 10000) + return () => clearInterval(interval) + }, [params.id]) + + const initializeClient = async () => { + try { + // Only initialize client on the client side + if (typeof window === 'undefined') return + + 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' + if (tokenId === 'BTC') return 'BTC' + 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) + + // If swap involves BTC, also get BTC address + if (swap && offerInvolvesBTC(swap.offer!)) { + try { + const btcAddress = await (client as any).getBTCAddress() + setUserBTCAddress(btcAddress) + } catch (error) { + console.error('Error getting BTC address:', error) + // Don't fail the whole connection for BTC 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 || !swap) { + alert('Please connect your wallet first') + return + } + + setGeneratingSecret(true) + try { + const secretHashResponse = await client.requestSecretHash({}) + setSecretHash(secretHashResponse) + console.log('Generated secret hash:', secretHashResponse) + + // Save the secret hash to the database + const response = await fetch(`/api/swaps/${swap.id}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + secretHash: JSON.stringify(secretHashResponse) + }), + }) + + if (!response.ok) { + throw new Error('Failed to save secret hash') + } + + // Refresh swap data to show the updated secret hash + fetchSwap() + + } 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) + // Check if creator offered BTC or ML + const creatorOfferedBTC = swap.offer && isCreatorOfferingBTC(swap.offer) + + if (creatorOfferedBTC) { + // Creator offered BTC, so taker should claim BTC HTLC + alert('Creator offered BTC. Please use the "Claim BTC" button in the BTC HTLC section below.') + return + } + + if (!swap.creatorHtlcTxHash) { + alert('Creator ML 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 { + let extractedSecret: string + + // Check if there's a BTC claim transaction to extract secret from + if (swap.btcClaimTxId) { + console.log('Extracting secret from BTC claim transaction:', swap.btcClaimTxId) + const network = (process.env.NEXT_PUBLIC_MINTLAYER_NETWORK as 'testnet' | 'mainnet') || 'testnet' + extractedSecret = await extractSecretFromBTCTransaction(swap.btcClaimTxId, network === 'testnet') + } + // Fallback to ML claim transaction + else if (swap.claimTxHex) { + console.log('Extracting secret from ML claim transaction:', swap.claimTxHash) + // Extract secret using the ML claim transaction data + extractedSecret = await client.extractHtlcSecret({ + transaction_id: swap.claimTxHash || '', // The claim transaction ID + transaction_hex: swap.claimTxHex, // The claim transaction hex (contains the secret) + format: 'hex' + }) + } else { + alert('No claim transaction found to extract secret from') + return + } + + 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 + } + + // Determine which HTLC the taker should claim based on what the creator offered + const creatorOfferedBTC = swap.offer && isCreatorOfferingBTC(swap.offer) + + if (creatorOfferedBTC) { + // Creator offered BTC, so taker should claim the BTC HTLC + if (!swap.btcHtlcTxId) { + alert('Creator BTC HTLC not found') + return + } + // Redirect to BTC claiming function + alert('Please use the "Claim BTC" button in the BTC HTLC section below to claim the creator\'s BTC.') + return + } else { + // Creator offered ML, so taker should claim the ML HTLC + if (!swap.creatorHtlcTxHash) { + console.log('swap', swap); + alert('Creator ML HTLC not found') + return + } + } + + setClaimingHtlc(true) + try { + // Use the extracted secret to claim creator's ML 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) + } + } + + // BTC HTLC Functions + const createBTCHTLC = async () => { + if (!client || !userAddress || !swap?.offer || !swap.secretHash) { + alert('Missing required data for BTC HTLC creation') + return + } + + if (!offerInvolvesBTC(swap.offer)) { + alert('This swap does not involve BTC') + return + } + + setCreatingBTCHtlc(true) + try { + validateSwapForBTCHTLC(swap, swap.offer) + + const isUserCreator = swap.offer.creatorMLAddress === userAddress + let request; + + if (isUserCreator && isCreatorOfferingBTC(swap.offer)) { + // Creator is offering BTC + request = buildCreatorBTCHTLCRequest(swap, swap.offer, swap.secretHash) + } else if (!isUserCreator && !isCreatorOfferingBTC(swap.offer)) { + // Taker is offering BTC (creator wants BTC) + request = buildTakerBTCHTLCRequest(swap, swap.offer, swap.secretHash) + } else { + alert('You are not the one who should create the BTC HTLC') + return + } + + // Create BTC HTLC via wallet + const response = await (client as any).request({ method: 'signTransaction', params: { chain: 'bitcoin', txData: { JSONRepresentation: request } } }) + + // Broadcast the transaction using Blockstream API + const network = (process.env.NEXT_PUBLIC_MINTLAYER_NETWORK as 'testnet' | 'mainnet') || 'testnet' + const txId = await broadcastBTCTransaction(response.signedTxHex, network === 'testnet') + + // Update swap with BTC HTLC details + await fetch(`/api/swaps/${swap.id}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + btcHtlcAddress: response.htlcAddress, + btcHtlcTxId: txId, + btcHtlcTxHex: response.signedTxHex, + btcRedeemScript: response.redeemScript, + status: swap.creatorHtlcTxHash || swap.takerHtlcTxHash ? 'both_htlcs_created' : 'btc_htlc_created' + }) + }) + + fetchSwap() + alert(`BTC HTLC created successfully! TX ID: ${txId}`) + } catch (error) { + console.error('Error creating BTC HTLC:', error) + alert('Failed to create BTC HTLC. Please try again.') + } finally { + setCreatingBTCHtlc(false) + } + } + + const claimBTCHTLC = async () => { + if (!client || !userAddress || !swap?.offer) { + alert('Missing required data for BTC HTLC claiming') + return + } + + // Get the user's BTC address from swap data based on their role + const isUserCreator = swap.offer.creatorMLAddress === userAddress + const usersBTCAddress = isUserCreator ? swap.offer.creatorBTCAddress : swap.takerBTCAddress + + if (!usersBTCAddress) { + alert('No BTC address found for claiming') + return + } + + if (!swap.btcHtlcTxId || !swap.btcRedeemScript) { + alert('BTC HTLC not found or missing redeem script') + return + } + + let secret = swap.secret + + // If user is taker and no secret is stored, they need to provide it + if (!isUserCreator && !secret) { + // @ts-ignore + secret = prompt('Please enter the secret to claim BTC:') + if (!secret) return + } + + setClaimingBTCHtlc(true) + try { + const network = (process.env.NEXT_PUBLIC_MINTLAYER_NETWORK as 'testnet' | 'mainnet') || 'testnet' + const request = await buildBTCHTLCSpendRequest(swap, secret || '', usersBTCAddress, network === 'testnet') + + // Spend BTC HTLC via wallet + const response = await (client as any).request({ method: 'signTransaction', params: { chain: 'bitcoin', txData: { JSONRepresentation: request } } }) + + // Broadcast the claim transaction using Blockstream API + const txId = await broadcastBTCTransaction(response.signedTxHex, network === 'testnet') + + // Determine if this completes the atomic swap + // In BTC/ML swaps, if both ML and BTC have been claimed, the swap is fully completed + const mlAlreadyClaimed = swap.claimTxHash // ML HTLC was already claimed + const finalStatus = mlAlreadyClaimed ? 'fully_completed' : 'completed' + + // Update swap with claim details + await fetch(`/api/swaps/${swap.id}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + btcClaimTxId: txId, + btcClaimTxHex: response.signedTxHex, + secret: secret, + status: finalStatus + }) + }) + + fetchSwap() + alert(`BTC HTLC claimed successfully! TX ID: ${txId}`) + } catch (error) { + console.error('Error claiming BTC HTLC:', error) + alert('Failed to claim BTC HTLC. Please try again.') + } finally { + setClaimingBTCHtlc(false) + } + } + + const refundBTCHTLC = async () => { + if (!client || !userAddress || !swap?.offer) { + alert('Missing required data for BTC HTLC refund') + return + } + + // Get the user's BTC address from swap data based on their role + const isUserCreator = swap.offer.creatorMLAddress === userAddress + const usersBTCAddress = isUserCreator ? swap.offer.creatorBTCAddress : swap.takerBTCAddress + + if (!usersBTCAddress) { + alert('No BTC address found for refund') + return + } + + if (!swap.btcHtlcTxId || !swap.btcRedeemScript) { + alert('BTC HTLC not found or missing redeem script') + return + } + + setRefundingBTCHtlc(true) + try { + const network = (process.env.NEXT_PUBLIC_MINTLAYER_NETWORK as 'testnet' | 'mainnet') || 'testnet' + const request = await buildBTCHTLCRefundRequest(swap, usersBTCAddress, network === 'testnet') + + // Refund BTC HTLC via wallet + const response = await (client as any).request({ method: 'signTransaction', params: { chain: 'bitcoin', txData: { JSONRepresentation: request } } }) + + // Broadcast the refund transaction using Blockstream API + const txId = await broadcastBTCTransaction(response.signedTxHex, network === 'testnet') + + // Update swap with refund details + await fetch(`/api/swaps/${swap.id}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + btcRefundTxId: txId, + btcRefundTxHex: response.signedTxHex, + status: 'btc_refunded' + }) + }) + + fetchSwap() + alert(`BTC HTLC refunded successfully! TX ID: ${txId}`) + } catch (error) { + console.error('Error refunding BTC HTLC:', error) + alert('Failed to refund BTC HTLC. Please try again.') + } finally { + setRefundingBTCHtlc(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 'btc_htlc_created': return 'text-orange-600 bg-orange-100' + case 'both_htlcs_created': return 'text-purple-600 bg-purple-100' + case 'in_progress': return 'text-purple-600 bg-purple-100' + case 'completed': return 'text-green-600 bg-green-100' + case 'fully_completed': return 'text-green-700 bg-green-200' + case 'refunded': return 'text-red-600 bg-red-100' + case 'btc_refunded': return 'text-red-700 bg-red-200' + 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 'ML HTLC created, waiting for counterparty' + case 'btc_htlc_created': return 'BTC HTLC created, waiting for ML HTLC' + case 'both_htlcs_created': return 'Both HTLCs created, ready to claim' + case 'in_progress': return 'Claims in progress' + case 'completed': return 'First claim completed, waiting for final claim' + case 'fully_completed': return 'Atomic swap completed successfully' + case 'refunded': return 'ML HTLC refunded due to timeout' + case 'btc_refunded': return 'BTC HTLC refunded due to timeout' + 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 ML: +
+ {swap.offer?.creatorMLAddress} +
+ {swap.offer?.creatorBTCAddress && ( + <> + Creator BTC: + + + )} +
+
+ Taker ML: +
+ {swap.takerMLAddress} +
+ {swap.takerBTCAddress && ( + <> + Taker BTC: + + + )} +
+
+
+ +
+
+
Created: {new Date(swap.createdAt).toLocaleString()}
+ {swap.offer?.contact && ( +
Contact: {swap.offer.contact}
+ )} +
+
+
+
+ + {/* Progress Steps */} +
+

Progress

+ +
+
+
+ Offer accepted +
+ +
+
+ ML HTLC created +
+ + {swap.offer && offerInvolvesBTC(swap.offer) && ( +
+
+ BTC HTLC created +
+ )} + +
+
+ Both HTLCs ready +
+ +
+
+ + First claim completed (secret revealed) + {swap.btcClaimTxId && !swap.claimTxHash && ' - BTC claimed'} + {swap.claimTxHash && !swap.btcClaimTxId && ' - ML claimed'} + {swap.claimTxHash && swap.btcClaimTxId && ' - Both claimed'} + +
+ +
+
+ Secret extracted +
+ +
+
+ Atomic swap completed +
+
+
+
+ + {/* 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 +

+ + {/* Show appropriate HTLC creation button based on what creator is offering */} + {swap.offer && offerInvolvesBTC(swap.offer) ? ( + // BTC is involved - creator creates the HTLC for what they're offering + isCreatorOfferingBTC(swap.offer) ? ( + // Creator offers BTC -> create BTC HTLC first + + ) : ( + // Creator offers ML -> create ML HTLC first + + ) + ) : ( + // No BTC involved - standard ML HTLC + + )} +
+ )} +
+ )} +
+ )} + + {(swap.status === 'htlc_created' || swap.status === 'btc_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 && ( +
ML TX ID: {swap.creatorHtlcTxHash.slice(0, 20)}...
+ )} + {swap.btcHtlcTxId && ( +
BTC TX ID: {swap.btcHtlcTxId.slice(0, 20)}...
+ )} +
+
+
+

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

+ + {/* Show appropriate button based on what taker needs to create */} + {swap.offer && offerInvolvesBTC(swap.offer) && !isCreatorOfferingBTC(swap.offer) ? ( + // Creator offered ML, taker needs to create BTC HTLC (handled in BTC section) +

+ BTC HTLC creation is handled in the BTC section below. +

+ ) : ( + // Standard ML counterparty HTLC + + )} +
+
+ )} +
+ )} + + {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.btcClaimTxId) && ( +
+

+ {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!" + } +

+ + {/* Show ML claim transaction if it exists */} + {swap.claimTxHash && ( +

+ ML Claim Transaction: {swap.claimTxHash} +

+ )} + + {/* Show BTC claim transaction if it exists */} + {swap.btcClaimTxId && ( +

+ BTC Claim Transaction: {swap.btcClaimTxId} +

+ )} + + {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. +

+
+ )} + + {/* BTC HTLC Section - Show when appropriate */} + {swap.offer && offerInvolvesBTC(swap.offer) && ( + // Show BTC HTLC section when: + // 1. Creator offered BTC and BTC HTLC exists, OR + // 2. Creator offered ML, ML HTLC exists, and it's time for taker to create BTC HTLC + (isCreatorOfferingBTC(swap.offer) || swap.creatorHtlcTxHash) && ( +
+

+ BTC HTLC {isCreatorOfferingBTC(swap.offer) ? '(Creator → Taker)' : '(Taker → Creator)'} +

+ + {swap.btcHtlcAddress ? ( +
+
+

BTC HTLC Contract Address:

+ + {swap.btcHtlcAddress} + +
+ + {swap.btcHtlcTxId && ( +
+

BTC Funding Transaction:

+ + {swap.btcHtlcTxId} + +
+ )} + + {swap.btcClaimTxId && ( +
+

BTC Claim Transaction:

+ + {swap.btcClaimTxId} + +
+ )} + + {!swap.btcClaimTxId && userAddress && ( +
+ {/* Use the BTC address from swap data based on user role */} + {(() => { + const isUserCreator = swap.offer?.creatorMLAddress === userAddress + const usersBTCAddress = isUserCreator ? swap.offer?.creatorBTCAddress : swap.takerBTCAddress + + return usersBTCAddress ? ( +

+ Your BTC Address: {usersBTCAddress} +

+ ) : ( +

+ No BTC address found in swap data +

+ ) + })()} + +
+ + + +
+
+ )} +
+ ) : ( +
+

+ {/* Show appropriate message based on who should create BTC HTLC */} + {isCreatorOfferingBTC(swap.offer) ? ( + // Creator offers BTC - creator should create BTC HTLC (handled in pending section above) + isUserCreator + ? "You need to create the BTC HTLC first." + : "Waiting for creator to create BTC HTLC." + ) : ( + // Creator offers ML - taker should create BTC HTLC after ML HTLC exists + !isUserCreator + ? "You need to create the BTC HTLC." + : "Waiting for taker to create BTC HTLC." + )} +

+ + {/* Show BTC HTLC creation button for taker when creator offered ML and ML HTLC exists */} + {!isCreatorOfferingBTC(swap.offer) && !isUserCreator && swap.creatorHtlcTxHash && ( + + )} +
+ )} +
+ ) + )} + + {(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/btc-request-builder.ts b/examples/swap-board-ml-btc/src/lib/btc-request-builder.ts new file mode 100644 index 0000000..a779343 --- /dev/null +++ b/examples/swap-board-ml-btc/src/lib/btc-request-builder.ts @@ -0,0 +1,347 @@ +import { Client } from '@mintlayer/sdk' +import { BTCHTLCCreateRequest, BTCHTLCSpendRequest, BTCHTLCRefundRequest } from '../types/btc-wallet' +import { Offer, Swap } from '../types/swap' + +/** + * Convert BTC amount to satoshis + */ +export function convertToBTCSatoshis(btcAmount: string): string { + const btc = parseFloat(btcAmount) + const satoshis = Math.round(btc * 100000000) // 1 BTC = 100,000,000 satoshis + return satoshis.toString() +} + +/** + * Convert satoshis to BTC + */ +export function convertFromBTCSatoshis(satoshis: string): string { + const sats = parseInt(satoshis) + const btc = sats / 100000000 + return btc.toString() +} + +/** + * Validate BTC address format (basic validation) + */ +export function isValidBTCAddress(address: string): boolean { + // Basic validation - starts with 1, 3, or bc1 for mainnet, or m, 2, tb1 for testnet + const mainnetRegex = /^[13][a-km-zA-HJ-NP-Z1-9]{25,34}$|^bc1[a-z0-9]{39,59}$/ + const testnetRegex = /^[mn2][a-km-zA-HJ-NP-Z1-9]{25,34}$|^tb1[a-z0-9]{39,59}$/ + + return mainnetRegex.test(address) || testnetRegex.test(address) +} + +/** + * Validate BTC public key format + */ +export function isValidBTCPublicKey(publicKey: string): boolean { + // Compressed public key: 33 bytes (66 hex chars), starts with 02 or 03 + // Uncompressed public key: 65 bytes (130 hex chars), starts with 04 + const compressedRegex = /^0[23][0-9a-fA-F]{64}$/ + const uncompressedRegex = /^04[0-9a-fA-F]{128}$/ + + return compressedRegex.test(publicKey) || uncompressedRegex.test(publicKey) +} + +/** + * Get BTC amount from offer based on swap direction + */ +export function getBTCAmountFromOffer(offer: Offer, isCreatorSide: boolean): string { + if (offer.tokenA === 'BTC') { + return isCreatorSide ? offer.amountA : offer.amountB + } else if (offer.tokenB === 'BTC') { + return isCreatorSide ? offer.amountB : offer.amountA + } + throw new Error('No BTC amount found in offer') +} + +/** + * Check if offer involves BTC + */ +export function offerInvolvesBTC(offer: Offer): boolean { + return offer.tokenA === 'BTC' || offer.tokenB === 'BTC' +} + +/** + * Check if creator is offering BTC + */ +export function isCreatorOfferingBTC(offer: Offer): boolean { + return offer.tokenA === 'BTC' +} + +/** + * Check if taker is offering BTC (creator wants BTC) + */ +export function isTakerOfferingBTC(offer: Offer): boolean { + return offer.tokenB === 'BTC' +} + +/** + * Build BTC HTLC create request for creator + */ +export function buildCreatorBTCHTLCRequest( + swap: Swap, + offer: Offer, + secretHash: string, + timeoutBlocks: number = 144 // ~24 hours +): BTCHTLCCreateRequest { + if (!isCreatorOfferingBTC(offer)) { + throw new Error('Creator is not offering BTC') + } + + if (!swap.takerBTCPublicKey || !offer.creatorBTCPublicKey) { + throw new Error('Missing required BTC public keys') + } + + return { + amount: convertToBTCSatoshis(offer.amountA), + secretHash: secretHash, + recipientPublicKey: swap.takerBTCPublicKey, // Taker can claim with secret + refundPublicKey: offer.creatorBTCPublicKey, // Creator can refund after timeout + timeoutBlocks: timeoutBlocks + } +} + +/** + * Build BTC HTLC create request for taker + */ +export function buildTakerBTCHTLCRequest( + swap: Swap, + offer: Offer, + secretHash: string, + timeoutBlocks: number = 144 // ~24 hours +): BTCHTLCCreateRequest { + if (!isTakerOfferingBTC(offer)) { + throw new Error('Taker is not offering BTC') + } + + if (!offer.creatorBTCPublicKey || !swap.takerBTCPublicKey) { + throw new Error('Missing required BTC public keys') + } + + return { + amount: convertToBTCSatoshis(offer.amountB), + secretHash: secretHash, + recipientPublicKey: offer.creatorBTCPublicKey, // Creator can claim with secret + refundPublicKey: swap.takerBTCPublicKey, // Taker can refund after timeout + timeoutBlocks: timeoutBlocks + } +} + +/** + * Build BTC HTLC spend request with UTXO data + */ +export async function buildBTCHTLCSpendRequest( + swap: Swap, + secret: string, + destinationAddress: string, + isTestnet: boolean = true +): Promise { + if (!swap.btcHtlcTxId || !swap.btcRedeemScript || !swap.btcHtlcAddress) { + throw new Error('Missing BTC HTLC transaction ID, redeem script, or HTLC address') + } + + // Fetch the HTLC UTXO data from Blockstream API + const htlcUtxo = await findHTLCUTXO(swap.btcHtlcTxId, swap.btcHtlcAddress, isTestnet) + + return { + type: 'spendHtlc', + utxo: htlcUtxo, + redeemScriptHex: swap.btcRedeemScript, + to: destinationAddress, + secret: secret + } +} + +/** + * Build BTC HTLC refund request with UTXO data + */ +export async function buildBTCHTLCRefundRequest( + swap: Swap, + destinationAddress: string, + isTestnet: boolean = true +): Promise { + if (!swap.btcHtlcTxId || !swap.btcRedeemScript || !swap.btcHtlcAddress) { + throw new Error('Missing BTC HTLC transaction ID, redeem script, or HTLC address') + } + + // Fetch the HTLC UTXO data from Blockstream API + const htlcUtxo = await findHTLCUTXO(swap.btcHtlcTxId, swap.btcHtlcAddress, isTestnet) + + return { + type: 'refundHtlc', + utxo: htlcUtxo, + redeemScriptHex: swap.btcRedeemScript, + to: destinationAddress + } +} + +/** + * Get timeout blocks for BTC HTLC based on role + * Creator should have longer timeout than taker for security + */ +export function getBTCTimeoutBlocks(isCreator: boolean): number { + // Creator gets longer timeout (48 hours), taker gets shorter (24 hours) + // This ensures taker claims first, then creator can claim ML side + return isCreator ? 288 : 144 +} + +/** + * Validate swap has required BTC data for HTLC creation + */ +export function validateSwapForBTCHTLC(swap: Swap, offer: Offer): void { + if (!offerInvolvesBTC(offer)) { + throw new Error('Offer does not involve BTC') + } + + if (isCreatorOfferingBTC(offer)) { + if (!offer.creatorBTCAddress || !offer.creatorBTCPublicKey) { + throw new Error('Missing creator BTC credentials') + } + if (!swap.takerBTCAddress || !swap.takerBTCPublicKey) { + throw new Error('Missing taker BTC credentials') + } + } else { + if (!offer.creatorBTCAddress || !offer.creatorBTCPublicKey) { + throw new Error('Missing creator BTC credentials') + } + if (!swap.takerBTCAddress || !swap.takerBTCPublicKey) { + throw new Error('Missing taker BTC credentials') + } + } + + if (!swap.secretHash) { + throw new Error('Missing secret hash') + } +} + +/** + * Get BTC block explorer URL for transaction + */ +export function getBTCExplorerURL(txId: string, isTestnet: boolean = true): string { + const baseUrl = isTestnet + ? 'https://blockstream.info/testnet/tx/' + : 'https://blockstream.info/tx/' + return `${baseUrl}${txId}` +} + +/** + * Get BTC block explorer URL for address + */ +export function getBTCAddressExplorerURL(address: string, isTestnet: boolean = true): string { + const baseUrl = isTestnet + ? 'https://blockstream.info/testnet/address/' + : 'https://blockstream.info/address/' + return `${baseUrl}${address}` +} + +/** + * Fetch UTXO data for a BTC address from Blockstream API + */ +export async function fetchBTCUTXOs(address: string, isTestnet: boolean = true): Promise { + const apiUrl = isTestnet + ? `https://blockstream.info/testnet/api/address/${address}/utxo` + : `https://blockstream.info/api/address/${address}/utxo` + + try { + const response = await fetch(apiUrl) + + if (!response.ok) { + throw new Error(`Failed to fetch UTXOs: ${response.status}`) + } + + const utxos = await response.json() + return utxos + } catch (error) { + console.error('Error fetching BTC UTXOs:', error) + throw error + } +} + +/** + * Find the HTLC UTXO from a BTC HTLC transaction + */ +export async function findHTLCUTXO(htlcTxId: string, htlcAddress: string, isTestnet: boolean = true): Promise { + try { + // Fetch UTXOs for the HTLC address + const utxos = await fetchBTCUTXOs(htlcAddress, isTestnet) + + // Find the UTXO that matches our HTLC transaction + const htlcUtxo = utxos.find(utxo => utxo.txid === htlcTxId) + + if (!htlcUtxo) { + throw new Error(`HTLC UTXO not found for transaction ${htlcTxId}`) + } + + return htlcUtxo + } catch (error) { + console.error('Error finding HTLC UTXO:', error) + throw error + } +} + +/** + * Extract secret from BTC HTLC claim transaction + */ +export async function extractSecretFromBTCTransaction(txId: string, isTestnet: boolean = true): Promise { + const apiUrl = isTestnet + ? `https://blockstream.info/testnet/api/tx/${txId}` + : `https://blockstream.info/api/tx/${txId}` + + try { + const response = await fetch(apiUrl) + + if (!response.ok) { + throw new Error(`Failed to fetch BTC transaction: ${response.status}`) + } + + const txData = await response.json() + + // Look for inputs that have witness data (HTLC spend) + for (const input of txData.vin) { + if (input.witness && input.witness.length >= 2) { + // In BTC HTLC, the secret is typically the second witness item + const secret = input.witness[1] + if (secret && secret.length > 0) { + return secret + } + } + } + + throw new Error('No secret found in BTC transaction witness data') + } catch (error) { + console.error('Error extracting secret from BTC transaction:', error) + throw error + } +} + +/** + * Broadcast BTC transaction using Blockstream API + */ +export async function broadcastBTCTransaction(signedTxHex: string, isTestnet: boolean = true): Promise { + const apiUrl = isTestnet + ? 'https://blockstream.info/testnet/api/tx' + : 'https://blockstream.info/api/tx' + + try { + const response = await fetch(apiUrl, { + method: 'POST', + headers: { + 'Content-Type': 'text/plain', + }, + body: signedTxHex + }) + + if (!response.ok) { + const errorText = await response.text() + throw new Error(`Failed to broadcast BTC transaction: ${response.status} ${errorText}`) + } + + // The response is the transaction ID + const txId = await response.text() + return txId + } catch (error) { + console.error('Error broadcasting BTC transaction:', error) + throw error + } +} 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/btc-wallet.ts b/examples/swap-board-ml-btc/src/types/btc-wallet.ts new file mode 100644 index 0000000..e44d6f3 --- /dev/null +++ b/examples/swap-board-ml-btc/src/types/btc-wallet.ts @@ -0,0 +1,63 @@ +/** + * BTC wallet integration types for HTLC atomic swaps + */ + +export interface BTCHTLCCreateRequest { + amount: string // in satoshis + secretHash: string // hex format + recipientPublicKey: string // recipient's BTC public key (for claiming with secret) + refundPublicKey: string // refund public key (for timeout refund) + timeoutBlocks: number // BTC blocks +} + +export interface BTCHTLCCreateResponse { + signedTxHex: string // ready to broadcast + transactionId: string // transaction ID of the funding transaction + htlcAddress: string // generated HTLC contract address + redeemScript: string // for spending operations +} + +export interface BTCHTLCSpendRequest { + htlcTxId: string + redeemScript: string + secret: string // to claim + destinationAddress: string +} + +export interface BTCHTLCRefundRequest { + htlcTxId: string + redeemScript: string + destinationAddress: string +} + +export interface BTCHTLCSpendResponse { + signedTxHex: string + transactionId: string +} + +export interface BTCBroadcastResponse { + txId: string +} + +/** + * Extended wallet interface for BTC operations + */ +export 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 +} 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..4fc5c0b --- /dev/null +++ b/examples/swap-board-ml-btc/src/types/swap.ts @@ -0,0 +1,102 @@ +export interface Offer { + id: number + direction: string + tokenA: string + tokenB: string + amountA: string + amountB: string + price: number + creatorMLAddress: string + creatorBTCAddress?: string // Creator's BTC address (when offering BTC) + creatorBTCPublicKey?: string // Creator's BTC public key (when offering BTC) + contact?: string + status: 'open' | 'taken' | 'completed' | 'cancelled' + createdAt: Date +} + +export type SwapStatus = + | 'pending' + | 'htlc_created' + | 'btc_htlc_created' // BTC HTLC has been created + | 'both_htlcs_created' // Both ML and BTC HTLCs exist + | 'in_progress' + | 'completed' + | 'fully_completed' + | 'refunded' + | 'btc_refunded' // BTC side was refunded + +export interface Swap { + id: number + offerId: number + takerMLAddress: string + takerBTCAddress?: string // Taker's BTC address (when accepting BTC offer) + takerBTCPublicKey?: string // Taker's BTC public key (when accepting BTC offer) + status: SwapStatus + secretHash?: string + secret?: string + + // Mintlayer HTLC fields + creatorHtlcTxHash?: string + creatorHtlcTxHex?: string + takerHtlcTxHash?: string + takerHtlcTxHex?: string + claimTxHash?: string + claimTxHex?: string + + // 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: Date + offer?: Offer +} + +export interface CreateOfferRequest { + tokenA: string + tokenB: string + amountA: string + amountB: string + creatorMLAddress: string + creatorBTCAddress?: string // Creator's BTC address (when offering BTC) + creatorBTCPublicKey?: string // Creator's BTC public key (when offering BTC) + contact?: string +} + +export interface AcceptOfferRequest { + offerId: number + takerMLAddress: string + takerBTCAddress?: string // Taker's BTC address (when accepting BTC offer) + takerBTCPublicKey?: string // Taker's BTC public key (when accepting BTC offer) +} + +export interface UpdateSwapRequest { + status?: SwapStatus + secretHash?: string + secret?: string + + // Mintlayer HTLC updates + creatorHtlcTxHash?: string + creatorHtlcTxHex?: string + takerHtlcTxHash?: string + takerHtlcTxHex?: string + claimTxHash?: string + claimTxHex?: string + + // BTC HTLC updates + btcHtlcAddress?: string + btcRedeemScript?: string + btcHtlcTxId?: string + btcHtlcTxHex?: string + btcClaimTxId?: string + btcClaimTxHex?: string + btcRefundTxId?: string + btcRefundTxHex?: 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/test-btc-integration.js b/examples/swap-board-ml-btc/test-btc-integration.js new file mode 100644 index 0000000..09f261f --- /dev/null +++ b/examples/swap-board-ml-btc/test-btc-integration.js @@ -0,0 +1,169 @@ +/** + * Simple test script to verify BTC integration functionality + * Run with: node test-btc-integration.js + */ + +// Since we can't directly import TypeScript, let's implement the test functions here +function convertToBTCSatoshis(btcAmount) { + const btc = parseFloat(btcAmount) + const satoshis = Math.round(btc * 100000000) // 1 BTC = 100,000,000 satoshis + return satoshis.toString() +} + +function convertFromBTCSatoshis(satoshis) { + const sats = parseInt(satoshis) + const btc = sats / 100000000 + return btc.toString() +} + +function isValidBTCAddress(address) { + // Basic validation - starts with 1, 3, or bc1 for mainnet, or m, 2, tb1 for testnet + const mainnetRegex = /^[13][a-km-zA-HJ-NP-Z1-9]{25,34}$|^bc1[a-z0-9]{39,59}$/ + const testnetRegex = /^[mn2][a-km-zA-HJ-NP-Z1-9]{25,34}$|^tb1[a-z0-9]{39,59}$/ + + return mainnetRegex.test(address) || testnetRegex.test(address) +} + +function isValidBTCPublicKey(publicKey) { + // Compressed public key: 33 bytes (66 hex chars), starts with 02 or 03 + // Uncompressed public key: 65 bytes (130 hex chars), starts with 04 + const compressedRegex = /^0[23][0-9a-fA-F]{64}$/ + const uncompressedRegex = /^04[0-9a-fA-F]{128}$/ + + return compressedRegex.test(publicKey) || uncompressedRegex.test(publicKey) +} + +function offerInvolvesBTC(offer) { + return offer.tokenA === 'BTC' || offer.tokenB === 'BTC' +} + +function isCreatorOfferingBTC(offer) { + return offer.tokenA === 'BTC' +} + +function isTakerOfferingBTC(offer) { + return offer.tokenB === 'BTC' +} + +function buildCreatorBTCHTLCRequest(swap, offer, secretHash, timeoutBlocks = 144) { + if (!isCreatorOfferingBTC(offer)) { + throw new Error('Creator is not offering BTC') + } + + if (!swap.takerBTCPublicKey || !offer.creatorBTCPublicKey) { + throw new Error('Missing required BTC public keys') + } + + return { + amount: convertToBTCSatoshis(offer.amountA), + secretHash: secretHash, + recipientPublicKey: swap.takerBTCPublicKey, // Taker can claim with secret + refundPublicKey: offer.creatorBTCPublicKey, // Creator can refund after timeout + timeoutBlocks: timeoutBlocks + } +} + +function getBTCExplorerURL(txId, isTestnet = true) { + const baseUrl = isTestnet + ? 'https://blockstream.info/testnet/tx/' + : 'https://blockstream.info/tx/' + return `${baseUrl}${txId}` +} + +function getBTCAddressExplorerURL(address, isTestnet = true) { + const baseUrl = isTestnet + ? 'https://blockstream.info/testnet/address/' + : 'https://blockstream.info/address/' + return `${baseUrl}${address}` +} + +// Test data +const mockOffer = { + id: 1, + tokenA: 'BTC', + tokenB: 'ML', + amountA: '0.001', + amountB: '100', + creatorBTCAddress: 'tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx', + creatorBTCPublicKey: '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798' +} + +const mockSwap = { + id: 1, + offerId: 1, + takerMLAddress: 'tmt1qtest', + takerBTCAddress: 'tb1qtest', + takerBTCPublicKey: '0379be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798', + secretHash: 'abcd1234567890abcd1234567890abcd12345678', + offer: mockOffer +} + +console.log('🧪 Testing BTC Integration Functions\n') + +// Test 1: Amount conversion +console.log('1. Testing amount conversion:') +try { + const satoshis = convertToBTCSatoshis('0.001') + const btc = convertFromBTCSatoshis(satoshis) + console.log(` ✅ 0.001 BTC = ${satoshis} satoshis = ${btc} BTC`) +} catch (error) { + console.log(` ❌ Amount conversion failed: ${error.message}`) +} + +// Test 2: Address validation +console.log('\n2. Testing BTC address validation:') +const testAddresses = [ + 'tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx', // Valid testnet bech32 + '1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa', // Valid mainnet P2PKH + 'invalid-address' // Invalid +] + +testAddresses.forEach(addr => { + const isValid = isValidBTCAddress(addr) + console.log(` ${isValid ? '✅' : '❌'} ${addr}: ${isValid ? 'Valid' : 'Invalid'}`) +}) + +// Test 3: Public key validation +console.log('\n3. Testing BTC public key validation:') +const testPubKeys = [ + '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798', // Valid compressed + '0479be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8', // Valid uncompressed + 'invalid-pubkey' // Invalid +] + +testPubKeys.forEach(pubkey => { + const isValid = isValidBTCPublicKey(pubkey) + console.log(` ${isValid ? '✅' : '❌'} ${pubkey.slice(0, 20)}...: ${isValid ? 'Valid' : 'Invalid'}`) +}) + +// Test 4: Offer analysis +console.log('\n4. Testing offer analysis:') +console.log(` ✅ Offer involves BTC: ${offerInvolvesBTC(mockOffer)}`) +console.log(` ✅ Creator offering BTC: ${isCreatorOfferingBTC(mockOffer)}`) +console.log(` ✅ Taker offering BTC: ${isTakerOfferingBTC(mockOffer)}`) + +// Test 5: HTLC request building +console.log('\n5. Testing HTLC request building:') +try { + const creatorRequest = buildCreatorBTCHTLCRequest(mockSwap, mockOffer, mockSwap.secretHash) + console.log(' ✅ Creator BTC HTLC request built successfully') + console.log(` Amount: ${creatorRequest.amount} satoshis`) + console.log(` Recipient: ${creatorRequest.recipientPublicKey.slice(0, 20)}...`) + console.log(` Refund: ${creatorRequest.refundPublicKey.slice(0, 20)}...`) +} catch (error) { + console.log(` ❌ Creator HTLC request failed: ${error.message}`) +} + +// Test 6: Explorer URLs +console.log('\n6. Testing explorer URL generation:') +const testTxId = 'abcd1234567890abcd1234567890abcd1234567890abcd1234567890abcd1234' +const testAddress = 'tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx' + +console.log(` ✅ TX URL: ${getBTCExplorerURL(testTxId, true)}`) +console.log(` ✅ Address URL: ${getBTCAddressExplorerURL(testAddress, true)}`) + +console.log('\n🎉 BTC Integration tests completed!') +console.log('\n📝 Next steps:') +console.log(' 1. Implement wallet BTC methods in browser extension') +console.log(' 2. Test with actual wallet integration') +console.log(' 3. Create end-to-end swap test') 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"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 971c3b1..cff2d97 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -37,6 +37,55 @@ importers: specifier: ^4.0.0 version: 4.10.0(webpack@5.99.7) + examples/swap-board-ml-btc: + dependencies: + '@mintlayer/sdk': + specifier: workspace:* + version: link:../../packages/sdk + '@prisma/client': + specifier: ^5.7.0 + version: 5.22.0(prisma@5.22.0) + next: + specifier: 14.0.4 + version: 14.0.4(react-dom@18.3.1)(react@18.3.1) + prisma: + specifier: ^5.7.0 + version: 5.22.0 + react: + specifier: ^18.2.0 + version: 18.3.1 + react-dom: + specifier: ^18.2.0 + version: 18.3.1(react@18.3.1) + devDependencies: + '@types/node': + specifier: ^20.10.0 + version: 20.19.4 + '@types/react': + specifier: ^18.2.0 + version: 18.3.20 + '@types/react-dom': + specifier: ^18.2.0 + version: 18.3.7(@types/react@18.3.20) + autoprefixer: + specifier: ^10.4.16 + version: 10.4.21(postcss@8.5.6) + eslint: + specifier: ^8.55.0 + version: 8.57.1 + eslint-config-next: + specifier: 14.0.4 + version: 14.0.4(eslint@8.57.1)(typescript@5.8.3) + postcss: + specifier: ^8.4.32 + version: 8.5.6 + tailwindcss: + specifier: ^3.3.6 + version: 3.4.17 + typescript: + specifier: ^5.3.0 + version: 5.8.3 + examples/swap-board-ml-ml: dependencies: '@mintlayer/sdk':