Skip to content

Commit 177cf91

Browse files
committed
feat: GetSwapQuotes rpc
1 parent bfa7c87 commit 177cf91

File tree

12 files changed

+2279
-1534
lines changed

12 files changed

+2279
-1534
lines changed

cmd/boltzcli/boltzcli.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ func main() {
100100
app.Commands = []*cli.Command{
101101
getInfoCommand,
102102
getPairsCommand,
103+
getSwapQuoteCommand,
103104
getSwapCommand,
104105
swapInfoStreamCommand,
105106
listSwapsCommand,

cmd/boltzcli/commands.go

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,159 @@ var getPairsCommand = &cli.Command{
5353
},
5454
}
5555

56+
var getSwapQuoteCommand = &cli.Command{
57+
Name: "quote",
58+
Category: "Infos",
59+
Usage: "Get a fee quote for a swap",
60+
ArgsUsage: "<type>",
61+
Description: `Gets a detailed quote for a swap including fee breakdown.
62+
Type can be: submarine, reverse, or chain
63+
64+
Currency defaults by swap type:
65+
- submarine: --to is always BTC (lightning), --from defaults to BTC
66+
- reverse: --from is always BTC (lightning), --to defaults to LBTC
67+
- chain: both --from and --to must be specified
68+
69+
Examples:
70+
Get quote for a submarine swap receiving 100000 sats on lightning:
71+
> boltzcli quote submarine --receive 100000
72+
73+
Get quote for a reverse swap sending 100000 sats from lightning to L-BTC:
74+
> boltzcli quote reverse --send 100000
75+
76+
Get quote for a chain swap from BTC to L-BTC:
77+
> boltzcli quote chain --send 100000 --from BTC --to LBTC`,
78+
Action: getSwapQuote,
79+
Flags: []cli.Flag{
80+
jsonFlag,
81+
&cli.Uint64Flag{
82+
Name: "send",
83+
Usage: "Amount to send (in satoshis)",
84+
},
85+
&cli.Uint64Flag{
86+
Name: "receive",
87+
Usage: "Amount to receive (in satoshis)",
88+
},
89+
&cli.StringFlag{
90+
Name: "from",
91+
Usage: "Currency to swap from (BTC or LBTC). For reverse swaps, always BTC.",
92+
},
93+
&cli.StringFlag{
94+
Name: "to",
95+
Usage: "Currency to swap to (BTC or LBTC). For submarine swaps, always BTC.",
96+
},
97+
},
98+
}
99+
100+
func getSwapQuote(ctx *cli.Context) error {
101+
if ctx.NArg() < 1 {
102+
return cli.ShowSubcommandHelp(ctx)
103+
}
104+
105+
client := getClient(ctx)
106+
107+
typeStr := strings.ToLower(ctx.Args().First())
108+
var swapType boltzrpc.SwapType
109+
switch typeStr {
110+
case "submarine", "sub":
111+
swapType = boltzrpc.SwapType_SUBMARINE
112+
case "reverse", "rev":
113+
swapType = boltzrpc.SwapType_REVERSE
114+
case "chain":
115+
swapType = boltzrpc.SwapType_CHAIN
116+
default:
117+
return fmt.Errorf("invalid swap type: %s (use submarine, reverse, or chain)", typeStr)
118+
}
119+
120+
var from, to boltzrpc.Currency
121+
var err error
122+
123+
// Apply currency defaults/constraints based on swap type
124+
switch swapType {
125+
case boltzrpc.SwapType_SUBMARINE:
126+
// Submarine: on-chain → Lightning. To is always BTC (Lightning)
127+
to = boltzrpc.Currency_BTC
128+
fromStr := ctx.String("from")
129+
if fromStr != "" {
130+
from, err = parseCurrency(fromStr)
131+
if err != nil {
132+
return err
133+
}
134+
}
135+
case boltzrpc.SwapType_REVERSE:
136+
// Reverse: Lightning → on-chain. From is always BTC (Lightning)
137+
from = boltzrpc.Currency_BTC
138+
toStr := ctx.String("to")
139+
if toStr != "" {
140+
to, err = parseCurrency(toStr)
141+
if err != nil {
142+
return err
143+
}
144+
}
145+
case boltzrpc.SwapType_CHAIN:
146+
// Chain: both must be specified explicitly
147+
fromStr := ctx.String("from")
148+
toStr := ctx.String("to")
149+
if fromStr == "" || toStr == "" {
150+
return fmt.Errorf("chain swaps require both --from and --to to be specified")
151+
}
152+
from, err = parseCurrency(fromStr)
153+
if err != nil {
154+
return err
155+
}
156+
to, err = parseCurrency(toStr)
157+
if err != nil {
158+
return err
159+
}
160+
}
161+
162+
request := &boltzrpc.GetSwapQuoteRequest{
163+
Type: swapType,
164+
Pair: &boltzrpc.Pair{From: from, To: to},
165+
}
166+
167+
sendAmount := ctx.Uint64("send")
168+
receiveAmount := ctx.Uint64("receive")
169+
170+
if sendAmount > 0 && receiveAmount > 0 {
171+
return fmt.Errorf("specify either --send or --receive, not both")
172+
}
173+
if sendAmount == 0 && receiveAmount == 0 {
174+
return fmt.Errorf("specify either --send or --receive")
175+
}
176+
177+
if sendAmount > 0 {
178+
request.Amount = &boltzrpc.GetSwapQuoteRequest_SendAmount{SendAmount: sendAmount}
179+
} else {
180+
request.Amount = &boltzrpc.GetSwapQuoteRequest_ReceiveAmount{ReceiveAmount: receiveAmount}
181+
}
182+
183+
quote, err := client.GetSwapQuote(request)
184+
if err != nil {
185+
return err
186+
}
187+
188+
if ctx.Bool("json") {
189+
printJson(quote)
190+
return nil
191+
}
192+
193+
fmt.Printf("Swap Quote (%s)\n", swapType)
194+
fmt.Printf(" %s -> %s\n", from, to)
195+
fmt.Println()
196+
fmt.Printf(" Send Amount: %s\n", utils.Satoshis(quote.SendAmount))
197+
fmt.Printf(" Receive Amount: %s\n", utils.Satoshis(quote.ReceiveAmount))
198+
fmt.Println()
199+
fmt.Println("Fee Breakdown:")
200+
fmt.Printf(" Boltz Fee: %s (%.2f%%)\n", utils.Satoshis(quote.BoltzFee), quote.PairInfo.Fees.Percentage)
201+
fmt.Printf(" Network Fee: %s\n", utils.Satoshis(quote.NetworkFee))
202+
fmt.Printf(" Total Fee: %s\n", utils.Satoshis(quote.BoltzFee+quote.NetworkFee))
203+
fmt.Println()
204+
fmt.Printf("Limits: %s - %s\n", utils.Satoshis(quote.PairInfo.Limits.Minimal), utils.Satoshis(quote.PairInfo.Limits.Maximal))
205+
206+
return nil
207+
}
208+
56209
func getPairs(ctx *cli.Context) error {
57210
client := getClient(ctx)
58211
pairs, err := client.GetPairs()

docs/grpc.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,14 @@ Fetches all available pairs for submarine and reverse swaps.
8484
| ------- | -------- |
8585
| [`.google.protobuf.Empty`](#.google.protobuf.empty) | [`GetPairsResponse`](#getpairsresponse) |
8686

87+
#### GetSwapQuote
88+
89+
Gets a quote for a swap with fee breakdown. Allows specifying either the send amount or the receive amount.
90+
91+
| Request | Response |
92+
| ------- | -------- |
93+
| [`GetSwapQuoteRequest`](#getswapquoterequest) | [`GetSwapQuoteResponse`](#getswapquoteresponse) |
94+
8795
#### ListSwaps
8896

8997
Returns a list of all swaps, reverse swaps, and chain swaps in the database.
@@ -1026,6 +1034,39 @@ Channel creations are an optional extension to a submarine swap in the data type
10261034

10271035

10281036

1037+
#### GetSwapQuoteRequest
1038+
1039+
1040+
1041+
1042+
| Field | Type | Label | Description |
1043+
| ----- | ---- | ----- | ----------- |
1044+
| `type` | [`SwapType`](#swaptype) | | |
1045+
| `pair` | [`Pair`](#pair) | | |
1046+
| `send_amount` | [`uint64`](#uint64) | | The amount you want to send |
1047+
| `receive_amount` | [`uint64`](#uint64) | | The amount you want to receive |
1048+
1049+
1050+
1051+
1052+
1053+
#### GetSwapQuoteResponse
1054+
1055+
1056+
1057+
1058+
| Field | Type | Label | Description |
1059+
| ----- | ---- | ----- | ----------- |
1060+
| `send_amount` | [`uint64`](#uint64) | | The amount you will send |
1061+
| `receive_amount` | [`uint64`](#uint64) | | The amount you will receive |
1062+
| `boltz_fee` | [`uint64`](#uint64) | | Service fee charged by Boltz |
1063+
| `network_fee` | [`uint64`](#uint64) | | Network/miner fees |
1064+
| `pair_info` | [`PairInfo`](#pairinfo) | | The pair info with current limits |
1065+
1066+
1067+
1068+
1069+
10291070
#### GetTenantRequest
10301071

10311072

internal/macaroons/permissions.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,10 @@ var (
7373
Entity: "info",
7474
Action: "read",
7575
}},
76+
"/boltzrpc.Boltz/GetSwapQuote": {{
77+
Entity: "info",
78+
Action: "read",
79+
}},
7680
"/boltzrpc.Boltz/ListSwaps": {{
7781
Entity: "swap",
7882
Action: "read",

internal/rpcserver/router.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,74 @@ func (server *routedBoltzServer) GetPairInfo(_ context.Context, request *boltzrp
308308
}
309309
}
310310

311+
func (server *routedBoltzServer) GetSwapQuote(ctx context.Context, request *boltzrpc.GetSwapQuoteRequest) (*boltzrpc.GetSwapQuoteResponse, error) {
312+
// Apply currency constraints based on swap type
313+
pair := request.Pair
314+
if pair == nil {
315+
pair = &boltzrpc.Pair{}
316+
}
317+
318+
switch request.Type {
319+
case boltzrpc.SwapType_SUBMARINE:
320+
// Submarine: on-chain → Lightning. To is always BTC (Lightning)
321+
pair.To = boltzrpc.Currency_BTC
322+
case boltzrpc.SwapType_REVERSE:
323+
// Reverse: Lightning → on-chain. From is always BTC (Lightning)
324+
pair.From = boltzrpc.Currency_BTC
325+
case boltzrpc.SwapType_CHAIN:
326+
// Chain: both must be explicitly specified
327+
if request.Pair == nil {
328+
return nil, status.Errorf(codes.InvalidArgument, "chain swaps require both from and to currencies to be specified")
329+
}
330+
}
331+
332+
pairInfo, err := server.GetPairInfo(ctx, &boltzrpc.GetPairInfoRequest{Type: request.Type, Pair: pair})
333+
if err != nil {
334+
return nil, err
335+
}
336+
337+
swapType := serializers.ParseSwapType(request.Type)
338+
339+
var sendAmount, receiveAmount uint64
340+
341+
switch amt := request.Amount.(type) {
342+
case *boltzrpc.GetSwapQuoteRequest_SendAmount:
343+
sendAmount = amt.SendAmount
344+
case *boltzrpc.GetSwapQuoteRequest_ReceiveAmount:
345+
receiveAmount = amt.ReceiveAmount
346+
default:
347+
return nil, status.Errorf(codes.InvalidArgument, "either send_amount or receive_amount must be specified")
348+
}
349+
350+
quote := utils.CalculateSwapQuote(swapType, sendAmount, receiveAmount, pairInfo.Fees)
351+
352+
// Validate against limits
353+
var amountToCheck uint64
354+
switch request.Type {
355+
case boltzrpc.SwapType_SUBMARINE:
356+
// Submarine limits are on the receive (lightning) amount
357+
amountToCheck = quote.ReceiveAmount
358+
case boltzrpc.SwapType_REVERSE, boltzrpc.SwapType_CHAIN:
359+
// Reverse/Chain limits are on the send amount
360+
amountToCheck = quote.SendAmount
361+
}
362+
363+
if amountToCheck < pairInfo.Limits.Minimal {
364+
return nil, status.Errorf(codes.InvalidArgument, "amount %d is below minimum %d", amountToCheck, pairInfo.Limits.Minimal)
365+
}
366+
if amountToCheck > pairInfo.Limits.Maximal {
367+
return nil, status.Errorf(codes.InvalidArgument, "amount %d exceeds maximum %d", amountToCheck, pairInfo.Limits.Maximal)
368+
}
369+
370+
return &boltzrpc.GetSwapQuoteResponse{
371+
SendAmount: quote.SendAmount,
372+
ReceiveAmount: quote.ReceiveAmount,
373+
BoltzFee: quote.BoltzFee,
374+
NetworkFee: quote.NetworkFee,
375+
PairInfo: pairInfo,
376+
}, nil
377+
}
378+
311379
func (server *routedBoltzServer) GetServiceInfo(_ context.Context, request *boltzrpc.GetServiceInfoRequest) (*boltzrpc.GetServiceInfoResponse, error) {
312380
fees, limits, err := server.getPairs(boltz.PairBtc)
313381

internal/utils/fees.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package utils
22

33
import (
4+
"math"
5+
46
"github.com/BoltzExchange/boltz-client/v2/pkg/boltz"
57
"github.com/BoltzExchange/boltz-client/v2/pkg/boltzrpc"
68
)
@@ -9,3 +11,51 @@ func CalculateFeeEstimate(fees *boltzrpc.SwapFees, amount uint64) uint64 {
911
serviceFee := boltz.Percentage(fees.Percentage).Calculate(amount)
1012
return serviceFee + fees.MinerFees
1113
}
14+
15+
type SwapQuote struct {
16+
SendAmount uint64
17+
ReceiveAmount uint64
18+
BoltzFee uint64
19+
NetworkFee uint64
20+
}
21+
22+
func CalculateSwapQuote(swapType boltz.SwapType, sendAmount, receiveAmount uint64, fees *boltzrpc.SwapFees) *SwapQuote {
23+
percentage := boltz.Percentage(fees.Percentage)
24+
minerFee := fees.MinerFees
25+
26+
quote := &SwapQuote{
27+
NetworkFee: minerFee,
28+
}
29+
30+
if sendAmount > 0 {
31+
quote.SendAmount = sendAmount
32+
33+
switch swapType {
34+
case boltz.NormalSwap:
35+
// Submarine: service fee on receive, so receive = (send - minerFee) / (1 + rate)
36+
rate := percentage.Ratio()
37+
quote.ReceiveAmount = uint64(float64(sendAmount-minerFee) / (1 + rate))
38+
quote.BoltzFee = percentage.Calculate(quote.ReceiveAmount)
39+
case boltz.ReverseSwap, boltz.ChainSwap:
40+
// Reverse/Chain: service fee on send
41+
quote.BoltzFee = percentage.Calculate(sendAmount)
42+
quote.ReceiveAmount = sendAmount - quote.BoltzFee - minerFee
43+
}
44+
} else {
45+
quote.ReceiveAmount = receiveAmount
46+
47+
switch swapType {
48+
case boltz.NormalSwap:
49+
// Submarine: service fee on receive
50+
quote.BoltzFee = percentage.Calculate(receiveAmount)
51+
quote.SendAmount = receiveAmount + quote.BoltzFee + minerFee
52+
case boltz.ReverseSwap, boltz.ChainSwap:
53+
// Reverse/Chain: service fee on send, so send = (receive + minerFee) / (1 - rate)
54+
rate := percentage.Ratio()
55+
quote.SendAmount = uint64(math.Ceil(float64(receiveAmount+minerFee) / (1 - rate)))
56+
quote.BoltzFee = percentage.Calculate(quote.SendAmount)
57+
}
58+
}
59+
60+
return quote
61+
}

0 commit comments

Comments
 (0)