Skip to content

Commit 5f38f1b

Browse files
committed
fix: upgrade Boltz client to v2 API
- v2 requires refundPublicKey for submarine swaps and claimPublicKey for reverse - Generate ephemeral secp256k1 keypair for each swap - Updated all endpoints: /v2/swap/submarine, /v2/swap/reverse, /v2/swap/:id - Fixed pair info parsing for v2 response format
1 parent cb92454 commit 5f38f1b

File tree

3 files changed

+99
-112
lines changed

3 files changed

+99
-112
lines changed

src/app/api/swap/boltz/[id]/route.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,3 @@
1-
/**
2-
* GET /api/swap/boltz/[id] - Check Boltz swap status
3-
*/
41
import { NextRequest, NextResponse } from 'next/server';
52
import { getSwapStatus } from '@/lib/swap/boltz';
63

src/app/api/swap/boltz/route.ts

Lines changed: 3 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,3 @@
1-
/**
2-
* GET /api/swap/boltz - Get Boltz pair info (limits, fees)
3-
* POST /api/swap/boltz - Create a BTC ↔ Lightning swap
4-
*/
51
import { NextRequest, NextResponse } from 'next/server';
62
import {
73
getBoltzPairInfo,
@@ -28,37 +24,25 @@ export async function POST(request: NextRequest) {
2824
const { direction, invoice, refundAddress, amountSats, claimAddress } = body;
2925

3026
if (direction === 'in') {
31-
// On-chain BTC → Lightning
3227
if (!invoice) {
3328
return NextResponse.json({ success: false, error: 'Lightning invoice required' }, { status: 400 });
3429
}
3530
const swap = await createSwapIn(invoice, refundAddress);
3631
return NextResponse.json({ success: true, swap });
3732
} else if (direction === 'out') {
38-
// Lightning → On-chain BTC
3933
if (!amountSats || !claimAddress) {
40-
return NextResponse.json(
41-
{ success: false, error: 'amountSats and claimAddress required' },
42-
{ status: 400 },
43-
);
34+
return NextResponse.json({ success: false, error: 'amountSats and claimAddress required' }, { status: 400 });
4435
}
4536
const swap = await createSwapOut(amountSats, claimAddress);
4637
return NextResponse.json({ success: true, swap });
4738
} else if (direction === 'estimate') {
48-
// Fee estimation
4939
if (!amountSats || !body.swapDirection) {
50-
return NextResponse.json(
51-
{ success: false, error: 'amountSats and swapDirection (in/out) required' },
52-
{ status: 400 },
53-
);
40+
return NextResponse.json({ success: false, error: 'amountSats and swapDirection required' }, { status: 400 });
5441
}
5542
const estimate = await estimateSwapFee(body.swapDirection, amountSats);
5643
return NextResponse.json({ success: true, estimate });
5744
} else {
58-
return NextResponse.json(
59-
{ success: false, error: 'direction must be "in", "out", or "estimate"' },
60-
{ status: 400 },
61-
);
45+
return NextResponse.json({ success: false, error: 'direction must be "in", "out", or "estimate"' }, { status: 400 });
6246
}
6347
} catch (error) {
6448
return NextResponse.json(

src/lib/swap/boltz.ts

Lines changed: 96 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -1,89 +1,112 @@
11
/**
2-
* Boltz Exchange API client for BTC ↔ Lightning swaps
3-
* Docs: https://docs.boltz.exchange/v/api
2+
* Boltz Exchange API v2 client for BTC ↔ Lightning swaps
3+
* Docs: https://docs.boltz.exchange/v/api/v2
44
* No API key needed — public, non-custodial submarine swaps.
55
*/
66

7-
const BOLTZ_API_URL = 'https://api.boltz.exchange';
7+
import crypto from 'crypto';
88

9-
export interface BoltzPairInfo {
9+
const BOLTZ_API = 'https://api.boltz.exchange/v2';
10+
11+
// --- Types ---
12+
13+
export interface BoltzSubmarinePairInfo {
14+
hash: string;
1015
rate: number;
11-
limits: {
12-
minimal: number;
13-
maximal: number;
14-
};
15-
fees: {
16-
percentage: number;
17-
percentageSwapIn: number;
18-
minerFees: {
19-
baseAsset: {
20-
normal: number;
21-
reverse: { claim: number; lockup: number };
22-
};
23-
quoteAsset: {
24-
normal: number;
25-
reverse: { claim: number; lockup: number };
26-
};
27-
};
28-
};
16+
limits: { minimal: number; maximal: number; maximalZeroConf: number };
17+
fees: { percentage: number; minerFees: number };
2918
}
3019

3120
export interface BoltzSwapResponse {
3221
id: string;
33-
bip21?: string;
34-
address?: string;
35-
expectedAmount?: number;
36-
acceptZeroConf?: boolean;
37-
timeoutBlockHeight?: number;
22+
bip21: string;
23+
address: string;
24+
expectedAmount: number;
25+
acceptZeroConf: boolean;
26+
timeoutBlockHeight: number;
27+
claimAddress?: string;
3828
redeemScript?: string;
29+
swapTree?: unknown;
3930
}
4031

4132
export interface BoltzReverseSwapResponse {
4233
id: string;
4334
invoice: string;
44-
redeemScript: string;
4535
lockupAddress: string;
4636
timeoutBlockHeight: number;
4737
onchainAmount: number;
38+
redeemScript?: string;
39+
swapTree?: unknown;
4840
}
4941

5042
export interface BoltzSwapStatus {
5143
status: string;
52-
transaction?: {
53-
id: string;
54-
hex?: string;
44+
transaction?: { id: string; hex?: string };
45+
}
46+
47+
// --- Helpers ---
48+
49+
/** Generate an ephemeral keypair for refund/claim paths */
50+
function generateKeyPair() {
51+
const keyPair = crypto.generateKeyPairSync('ec', {
52+
namedCurve: 'secp256k1',
53+
publicKeyEncoding: { type: 'spki', format: 'der' },
54+
privateKeyEncoding: { type: 'pkcs8', format: 'der' },
55+
});
56+
// Extract raw 33-byte compressed public key from DER
57+
const derPub = Buffer.from(keyPair.publicKey);
58+
// DER SPKI for secp256k1 has a fixed header; the last 65 bytes are the uncompressed key
59+
const uncompressed = derPub.subarray(derPub.length - 65);
60+
const x = uncompressed.subarray(1, 33);
61+
const prefix = uncompressed[64] % 2 === 0 ? 0x02 : 0x03;
62+
const compressed = Buffer.concat([Buffer.from([prefix]), x]);
63+
return {
64+
publicKey: compressed.toString('hex'),
65+
privateKey: Buffer.from(keyPair.privateKey).toString('hex'),
5566
};
5667
}
5768

69+
// --- API ---
70+
5871
/**
59-
* Get BTC/BTC pair info (on-chain ↔ Lightning limits, fees, rates)
72+
* Get BTCBTC submarine swap pair info (limits, fees)
6073
*/
61-
export async function getBoltzPairInfo(): Promise<BoltzPairInfo> {
62-
const res = await fetch(`${BOLTZ_API_URL}/getpairs`);
63-
if (!res.ok) throw new Error(`Boltz getpairs failed: ${res.status}`);
74+
export async function getBoltzPairInfo(): Promise<BoltzSubmarinePairInfo> {
75+
const res = await fetch(`${BOLTZ_API}/swap/submarine`);
76+
if (!res.ok) throw new Error(`Boltz pairs failed: ${res.status}`);
77+
const data = await res.json();
78+
const pair = data?.BTC?.BTC;
79+
if (!pair) throw new Error('BTC/BTC submarine pair not found');
80+
return pair;
81+
}
82+
83+
export async function getBoltzReversePairInfo() {
84+
const res = await fetch(`${BOLTZ_API}/swap/reverse`);
85+
if (!res.ok) throw new Error(`Boltz reverse pairs failed: ${res.status}`);
6486
const data = await res.json();
65-
const pair = data.pairs?.['BTC/BTC'];
66-
if (!pair) throw new Error('BTC/BTC pair not found on Boltz');
87+
const pair = data?.BTC?.BTC;
88+
if (!pair) throw new Error('BTC/BTC reverse pair not found');
6789
return pair;
6890
}
6991

7092
/**
71-
* Create a Normal Swap: On-chain BTC → Lightning
72-
* User sends BTC to the returned address, Boltz pays the Lightning invoice.
73-
*
74-
* @param invoice - Lightning invoice (BOLT11) to be paid by Boltz
75-
* @param refundAddress - On-chain BTC address for refunds if swap fails
93+
* Create submarine swap: On-chain BTC → Lightning
94+
* User sends BTC to returned address, Boltz pays the invoice.
7695
*/
77-
export async function createSwapIn(invoice: string, refundAddress?: string): Promise<BoltzSwapResponse> {
96+
export async function createSwapIn(
97+
invoice: string,
98+
refundAddress?: string,
99+
): Promise<BoltzSwapResponse & { refundPrivateKey?: string }> {
100+
const kp = generateKeyPair();
101+
78102
const body: Record<string, unknown> = {
79-
type: 'submarine',
80-
pairId: 'BTC/BTC',
81-
orderSide: 'sell',
103+
from: 'BTC',
104+
to: 'BTC',
82105
invoice,
106+
refundPublicKey: kp.publicKey,
83107
};
84-
if (refundAddress) body.refundAddress = refundAddress;
85108

86-
const res = await fetch(`${BOLTZ_API_URL}/createswap`, {
109+
const res = await fetch(`${BOLTZ_API}/swap/submarine`, {
87110
method: 'POST',
88111
headers: { 'Content-Type': 'application/json' },
89112
body: JSON.stringify(body),
@@ -93,31 +116,29 @@ export async function createSwapIn(invoice: string, refundAddress?: string): Pro
93116
const err = await res.text();
94117
throw new Error(`Boltz createswap failed: ${res.status} - ${err}`);
95118
}
96-
return res.json();
119+
const swap = await res.json();
120+
return { ...swap, refundPrivateKey: kp.privateKey };
97121
}
98122

99123
/**
100-
* Create a Reverse Swap: Lightning → On-chain BTC
101-
* User pays a Lightning invoice, Boltz sends BTC to the on-chain address.
102-
*
103-
* @param onchainAmount - Amount in sats to receive on-chain
104-
* @param claimAddress - On-chain BTC address to receive funds
124+
* Create reverse swap: Lightning → On-chain BTC
125+
* User pays LN invoice, Boltz sends BTC on-chain.
105126
*/
106127
export async function createSwapOut(
107-
onchainAmount: number,
128+
invoiceAmount: number,
108129
claimAddress: string,
109-
preimageHash?: string,
110-
): Promise<BoltzReverseSwapResponse> {
130+
): Promise<BoltzReverseSwapResponse & { claimPrivateKey?: string }> {
131+
const kp = generateKeyPair();
132+
111133
const body: Record<string, unknown> = {
112-
type: 'reversesubmarine',
113-
pairId: 'BTC/BTC',
114-
orderSide: 'buy',
115-
onchainAmount,
134+
from: 'BTC',
135+
to: 'BTC',
136+
invoiceAmount,
116137
claimAddress,
138+
claimPublicKey: kp.publicKey,
117139
};
118-
if (preimageHash) body.preimageHash = preimageHash;
119140

120-
const res = await fetch(`${BOLTZ_API_URL}/createswap`, {
141+
const res = await fetch(`${BOLTZ_API}/swap/reverse`, {
121142
method: 'POST',
122143
headers: { 'Content-Type': 'application/json' },
123144
body: JSON.stringify(body),
@@ -127,55 +148,40 @@ export async function createSwapOut(
127148
const err = await res.text();
128149
throw new Error(`Boltz reverse swap failed: ${res.status} - ${err}`);
129150
}
130-
return res.json();
151+
const swap = await res.json();
152+
return { ...swap, claimPrivateKey: kp.privateKey };
131153
}
132154

133155
/**
134156
* Check swap status
135157
*/
136158
export async function getSwapStatus(swapId: string): Promise<BoltzSwapStatus> {
137-
const res = await fetch(`${BOLTZ_API_URL}/swapstatus`, {
138-
method: 'POST',
139-
headers: { 'Content-Type': 'application/json' },
140-
body: JSON.stringify({ id: swapId }),
141-
});
159+
const res = await fetch(`${BOLTZ_API}/swap/${swapId}`);
142160
if (!res.ok) {
143161
const err = await res.text();
144-
throw new Error(`Boltz swapstatus failed: ${res.status} - ${err}`);
162+
throw new Error(`Boltz status failed: ${res.status} - ${err}`);
145163
}
146164
return res.json();
147165
}
148166

149167
/**
150-
* Get fee estimation for a swap
168+
* Estimate swap fees
151169
*/
152170
export async function estimateSwapFee(
153171
direction: 'in' | 'out',
154172
amountSats: number,
155173
): Promise<{ totalFee: number; receiveSats: number; minerFee: number; serviceFee: number }> {
156-
const pair = await getBoltzPairInfo();
157-
158174
if (direction === 'in') {
159-
// On-chain → Lightning: user sends BTC, receives Lightning sats
160-
const serviceFee = Math.ceil(amountSats * (pair.fees.percentageSwapIn / 100));
161-
const minerFee = pair.fees.minerFees.baseAsset.normal;
175+
const pair = await getBoltzPairInfo();
176+
const serviceFee = Math.ceil(amountSats * (pair.fees.percentage / 100));
177+
const minerFee = pair.fees.minerFees;
162178
const totalFee = serviceFee + minerFee;
163-
return {
164-
totalFee,
165-
receiveSats: amountSats - totalFee,
166-
minerFee,
167-
serviceFee,
168-
};
179+
return { totalFee, receiveSats: amountSats - totalFee, minerFee, serviceFee };
169180
} else {
170-
// Lightning → On-chain: user pays LN invoice, receives on-chain BTC
181+
const pair = await getBoltzReversePairInfo();
171182
const serviceFee = Math.ceil(amountSats * (pair.fees.percentage / 100));
172-
const minerFee = pair.fees.minerFees.baseAsset.reverse.claim + pair.fees.minerFees.baseAsset.reverse.lockup;
183+
const minerFee = pair.fees.minerFees?.claim + pair.fees.minerFees?.lockup || 0;
173184
const totalFee = serviceFee + minerFee;
174-
return {
175-
totalFee,
176-
receiveSats: amountSats - totalFee,
177-
minerFee,
178-
serviceFee,
179-
};
185+
return { totalFee, receiveSats: amountSats - totalFee, minerFee, serviceFee };
180186
}
181187
}

0 commit comments

Comments
 (0)