Skip to content

Commit 73f3eda

Browse files
committed
support paying to lightning address
Adds support for WoS' LNURL pay mechanics to support paying to lightning addresses. I'm intentionally not supporting traditional bech32-encoded LNURLs, as they are in the process of deprecation. See https://github.com/lnurl/luds/blob/luds/17.md and lnurl/luds#66 (comment) Closes #1
1 parent ed0191b commit 73f3eda

File tree

3 files changed

+166
-5
lines changed

3 files changed

+166
-5
lines changed

examples_test.go

+30-1
Original file line numberDiff line numberDiff line change
@@ -46,12 +46,41 @@ func ExampleCredentials_OpenWallet() {
4646
panic(err)
4747
}
4848

49-
fmt.Println(wallet.LightningAddress())
49+
fmt.Println(wallet.LightningAddress().String())
5050

5151
// output:
5252
5353
}
5454

55+
func ExampleWallet_PayLightningAddress() {
56+
creds := wos.Credentials{
57+
APIToken: "edcc867c-96ff-4b0d-ba68-165c16071de0",
58+
APISecret: "91ul0rDKV1gANhQWWyEXhdWaSa6aQwAF",
59+
}
60+
61+
ctx := context.Background()
62+
wallet, err := creds.OpenWallet(ctx, nil)
63+
if err != nil {
64+
panic(err)
65+
}
66+
67+
68+
// API token: 6edf02b8-d4e9-4640-b7e4-90bc97f476ab
69+
// API secret: sgN5hn2RibvSba1vv260NvwnwVy0oiuh
70+
71+
lnAddress, err := wos.ParseLightningAddress("[email protected]")
72+
if err != nil {
73+
panic(err)
74+
}
75+
76+
payment, err := wallet.PayLightningAddress(ctx, lnAddress, "", 0.00000001)
77+
if err != nil {
78+
panic(err)
79+
}
80+
81+
fmt.Println(payment.Status)
82+
}
83+
5584
type RemoteSigner struct {
5685
URL string
5786
}

lnaddress.go

+52
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package wos
2+
3+
import (
4+
"errors"
5+
"regexp"
6+
"strings"
7+
)
8+
9+
// ErrInvalidLightningAddress is returned when parsing an invalid lightning address.
10+
var ErrInvalidLightningAddress = errors.New("invalid lightning address")
11+
12+
// This misses some edgecases. Might need to adjust in future.
13+
// https://stackoverflow.com/a/67686133
14+
var emailRegex = regexp.MustCompile(`^[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,4}$`)
15+
16+
// LightningAddress is a `[email protected]` internet identifier which
17+
// allows senders to request lightning invoices by contacting `domain.tld`,
18+
// who issues invoices on behalf of the `user`.
19+
type LightningAddress struct {
20+
Username string
21+
Domain string
22+
}
23+
24+
// String returns the [email protected] format of the address.
25+
func (a LightningAddress) String() string {
26+
return a.Username + "@" + a.Domain
27+
}
28+
29+
// LNURL returns the HTTPS URL used for LNURL payRequest, as per LUD-16.
30+
//
31+
// https://github.com/lnurl/luds/blob/luds/16.md
32+
func (a LightningAddress) LNURL() string {
33+
return "https://" + a.Domain + "/.well-known/lnurlp/" + a.Username
34+
}
35+
36+
// ParseLightningAddress parses a [LightningAddress] from a string, returning
37+
// ErrInvalidLightningAddress if the address is not a valid identifier.
38+
func ParseLightningAddress(lnAddress string) (LightningAddress, error) {
39+
if !emailRegex.MatchString(lnAddress) {
40+
return LightningAddress{}, ErrInvalidLightningAddress
41+
}
42+
43+
i := strings.Index(lnAddress, "@")
44+
username, domain := lnAddress[:i], lnAddress[i+1:]
45+
46+
addr := LightningAddress{
47+
Username: username,
48+
Domain: domain,
49+
}
50+
51+
return addr, nil
52+
}

wallet.go

+84-4
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ import (
1818
// BaseURL is the API URL for the Wallet of Satoshi API.
1919
const BaseURL = "https://www.livingroomofsatoshi.com"
2020

21+
// ErrOutsideSendableRange is returned when sending to a lightning address, but the amount
22+
// the caller asks to send is outside the range accepted by the receiver.
23+
var ErrOutsideSendableRange = errors.New("amount to send to LN address is outside the recipient's accepted range")
24+
2125
type errorResponse struct {
2226
Message string
2327
}
@@ -47,6 +51,14 @@ func checkHTTPResponse(resp *http.Response) error {
4751
return err
4852
}
4953

54+
func fromMillisat(sat uint64) float64 {
55+
return float64(sat) / 100_000_000.0 / 1_000.0
56+
}
57+
58+
func toMillisat(amount float64) uint64 {
59+
return uint64(amount * 100_000_000 * 1_000)
60+
}
61+
5062
// Credentials represents a full set of credentials for a WoS wallet.
5163
type Credentials struct {
5264
// APISecret is a base58 secret needed for write-access to a wallet.
@@ -102,7 +114,7 @@ type Wallet struct {
102114
signer Signer
103115
httpClient *http.Client
104116
onChainAddress string
105-
lightningAddress string
117+
lightningAddress LightningAddress
106118
}
107119

108120
// OpenWallet opens an existing wallet using a separate [Reader] and [Signer].
@@ -115,12 +127,17 @@ func OpenWallet(ctx context.Context, reader *Reader, signer Signer) (*Wallet, er
115127
return nil, fmt.Errorf("OpenWallet: %w", err)
116128
}
117129

130+
lnAddress, err := ParseLightningAddress(addresses.Lightning)
131+
if err != nil {
132+
return nil, fmt.Errorf("OpenWallet: %w", err)
133+
}
134+
118135
wallet := &Wallet{
119136
reader: reader,
120137
signer: signer,
121138
httpClient: reader.httpClient,
122139
onChainAddress: addresses.OnChain,
123-
lightningAddress: addresses.Lightning,
140+
lightningAddress: lnAddress,
124141
}
125142

126143
return wallet, nil
@@ -165,6 +182,11 @@ func CreateWallet(ctx context.Context, httpClient *http.Client) (*Wallet, *Crede
165182
return nil, nil, fmt.Errorf("error decoding CreateWallet response: %w", err)
166183
}
167184

185+
lnAddress, err := ParseLightningAddress(respStruct.LightningAddress)
186+
if err != nil {
187+
return nil, nil, fmt.Errorf("CreateWallet: %w", err)
188+
}
189+
168190
creds := &Credentials{
169191
APISecret: respStruct.APISecret,
170192
APIToken: respStruct.APIToken,
@@ -175,14 +197,14 @@ func CreateWallet(ctx context.Context, httpClient *http.Client) (*Wallet, *Crede
175197
signer: creds.SimpleSigner(),
176198
httpClient: httpClient,
177199
onChainAddress: respStruct.OnChainAddress,
178-
lightningAddress: respStruct.LightningAddress,
200+
lightningAddress: lnAddress,
179201
}
180202

181203
return wallet, creds, nil
182204
}
183205

184206
// LightningAddress returns the wallet's static Lightning Address.
185-
func (wallet *Wallet) LightningAddress() string {
207+
func (wallet *Wallet) LightningAddress() LightningAddress {
186208
return wallet.lightningAddress
187209
}
188210

@@ -389,6 +411,64 @@ func (wallet *Wallet) PayInvoice(ctx context.Context, invoice, description strin
389411
})
390412
}
391413

414+
// PayLightningAddress executes a payment of the given BTC amount to a
415+
// given lightning address. The description is stored in the WoS payment history.
416+
//
417+
// Returns ErrOutsideSendableRange if the amount to be sent is outside the receiver's
418+
// acceptable min/max sendable range.
419+
//
420+
// Under the hood, this uses the WoS API to proxy your request to [LightningAddress.Domain],
421+
// so that the recipient does not see your IP address.
422+
func (wallet *Wallet) PayLightningAddress(
423+
ctx context.Context,
424+
lnAddress LightningAddress,
425+
description string,
426+
amount float64,
427+
) (*Payment, error) {
428+
respData, err := wallet.PostRequest(ctx, "/api/v1/wallet/lnurl", map[string]any{
429+
"address": lnAddress.LNURL(),
430+
})
431+
if err != nil {
432+
return nil, fmt.Errorf("PayLightningAddress: %w", err)
433+
}
434+
435+
var lnPayResponseBody struct {
436+
Callback string `json:"callback"`
437+
MaxSendable uint64 `json:"maxSendable"`
438+
MinSendable uint64 `json:"minSendable"`
439+
}
440+
441+
if err := json.Unmarshal(respData, &lnPayResponseBody); err != nil {
442+
return nil, fmt.Errorf("PayLightningAddress: invalid response JSON: %w", err)
443+
}
444+
445+
if maxSendable := fromMillisat(lnPayResponseBody.MaxSendable); amount > maxSendable {
446+
return nil, fmt.Errorf(
447+
"PayLightningAddress: %w: exceeds maxSendable (%f BTC)",
448+
ErrOutsideSendableRange, maxSendable,
449+
)
450+
} else if minSendable := fromMillisat(lnPayResponseBody.MinSendable); amount < minSendable {
451+
return nil, fmt.Errorf(
452+
"PayLightningAddress: %w: exceeds minSendable (%f BTC)",
453+
ErrOutsideSendableRange, minSendable,
454+
)
455+
}
456+
457+
respData, err = wallet.PostRequest(ctx, "/api/v1/wallet/lnPay", map[string]any{
458+
"amount": toMillisat(amount),
459+
"callback": lnPayResponseBody.Callback,
460+
})
461+
if err != nil {
462+
return nil, fmt.Errorf("PayLightningAddress: %w", err)
463+
}
464+
465+
var payment Payment
466+
if err := json.Unmarshal(respData, &payment); err != nil {
467+
return nil, fmt.Errorf("PayLightningAddress: invalid response JSON: %w", err)
468+
}
469+
return &payment, nil
470+
}
471+
392472
// PayVariableInvoice executes a payment to a given variable-amount lightning invoice.
393473
// The description is stored in the WoS payment history.
394474
//

0 commit comments

Comments
 (0)