Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: bolt12 (WIP) #307

Draft
wants to merge 4 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion api/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ type API interface {
RedeemOnchainFunds(ctx context.Context, toAddress string) (*RedeemOnchainFundsResponse, error)
GetBalances(ctx context.Context) (*BalancesResponse, error)
ListTransactions(ctx context.Context, limit uint64, offset uint64) (*ListTransactionsResponse, error)
SendPayment(ctx context.Context, invoice string) (*SendPaymentResponse, error)
SendPayment(ctx context.Context, invoice string, amount *uint64) (*SendPaymentResponse, error)
CreateInvoice(ctx context.Context, amount int64, description string) (*MakeInvoiceResponse, error)
LookupInvoice(ctx context.Context, paymentHash string) (*LookupInvoiceResponse, error)
RequestMempoolApi(endpoint string) (interface{}, error)
Expand Down
4 changes: 2 additions & 2 deletions api/transactions.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,11 +51,11 @@ func (api *api) ListTransactions(ctx context.Context, limit uint64, offset uint6
return &apiTransactions, nil
}

func (api *api) SendPayment(ctx context.Context, invoice string) (*SendPaymentResponse, error) {
func (api *api) SendPayment(ctx context.Context, invoice string, amount *uint64) (*SendPaymentResponse, error) {
if api.svc.GetLNClient() == nil {
return nil, errors.New("LNClient not started")
}
transaction, err := api.svc.GetTransactionsService().SendPaymentSync(ctx, invoice, api.svc.GetLNClient(), nil, nil)
transaction, err := api.svc.GetTransactionsService().SendPaymentSync(ctx, invoice, amount, api.svc.GetLNClient(), nil, nil)
if err != nil {
return nil, err
}
Expand Down
50 changes: 46 additions & 4 deletions frontend/src/screens/wallet/Send.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,10 @@ export default function Send() {
const { data: csrf } = useCSRF();
const { toast } = useToast();
const [isLoading, setLoading] = React.useState(false);

const [invoice, setInvoice] = React.useState("");
const [offer, setOffer] = React.useState<string>();
const [amount, setAmount] = React.useState<string>();
const [invoiceDetails, setInvoiceDetails] = React.useState<Invoice | null>(
null
);
Expand All @@ -56,6 +59,11 @@ export default function Send() {

const handleContinue = () => {
try {
if (invoice.startsWith("lno1")) {
setOffer(invoice);
return;
}

setInvoiceDetails(new Invoice({ pr: invoice }));
} catch (error) {
toast({
Expand All @@ -75,7 +83,7 @@ export default function Send() {
}
setLoading(true);
const payInvoiceResponse = await request<PayInvoiceResponse>(
`/api/payments/${invoice}`,
`/api/payments/${invoice}?amount=${parseInt(amount || "0") * 1000}`,
{
method: "POST",
headers: {
Expand All @@ -87,7 +95,6 @@ export default function Send() {
if (payInvoiceResponse) {
setPayResponse(payInvoiceResponse);
setPaymentDone(true);
setInvoice("");
toast({
title: "Successfully paid invoice",
});
Expand All @@ -97,10 +104,12 @@ export default function Send() {
variant: "destructive",
title: "Failed to send: " + e,
});
setInvoice("");
setInvoiceDetails(null);
console.error(e);
}
setInvoice("");
setInvoiceDetails(null);
setOffer(undefined);
setAmount(undefined);
setLoading(false);
};

Expand Down Expand Up @@ -205,6 +214,39 @@ export default function Send() {
</Button>
</div>
</form>
) : offer ? (
<form onSubmit={handleSubmit} className="grid gap-5">
<div className="">
<p className="text-lg mb-5">Bolt12 Offer</p>
<Input
id="amount"
type="number"
autoFocus
required
placeholder="Amount in sats"
min={1}
value={amount}
onChange={(e) => {
setAmount(e.target.value.trim());
}}
/>
</div>
<div className="flex gap-5">
<LoadingButton
loading={isLoading}
type="submit"
autoFocus={!!invoiceDetails}
>
Confirm Payment
</LoadingButton>
<Button
onClick={() => setInvoiceDetails(null)}
variant="secondary"
>
Back
</Button>
</div>
</form>
) : (
<div className="grid gap-5">
<div className="">
Expand Down
10 changes: 9 additions & 1 deletion http/http_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -407,7 +407,15 @@ func (httpSvc *HttpService) balancesHandler(c echo.Context) error {
func (httpSvc *HttpService) sendPaymentHandler(c echo.Context) error {
ctx := c.Request().Context()

paymentResponse, err := httpSvc.api.SendPayment(ctx, c.Param("invoice"))
var amount *uint64

if amountParam := c.QueryParam("amount"); amountParam != "" {
if parsedAmount, err := strconv.ParseUint(amountParam, 10, 64); err == nil {
amount = &parsedAmount
}
}

paymentResponse, err := httpSvc.api.SendPayment(ctx, c.Param("invoice"), amount)

if err != nil {
return c.JSON(http.StatusInternalServerError, ErrorResponse{
Expand Down
4 changes: 4 additions & 0 deletions lnclient/breez/breez.go
Original file line number Diff line number Diff line change
Expand Up @@ -491,3 +491,7 @@ func (bs *BreezService) GetSupportedNIP47NotificationTypes() []string {
func (bs *BreezService) GetPubkey() string {
return bs.pubkey
}

func (bs *BreezService) PayOfferSync(ctx context.Context, offer string, amount uint64) (string, *lnclient.PayOfferResponse, error) {
return "", nil, errors.New("not supported")
}
4 changes: 4 additions & 0 deletions lnclient/cashu/cashu.go
Original file line number Diff line number Diff line change
Expand Up @@ -356,3 +356,7 @@ func (cs *CashuService) GetSupportedNIP47NotificationTypes() []string {
func (svc *CashuService) GetPubkey() string {
return ""
}

func (svc *CashuService) PayOfferSync(ctx context.Context, offer string, amount uint64) (string, *lnclient.PayOfferResponse, error) {
return "", nil, errors.New("not supported")
}
4 changes: 4 additions & 0 deletions lnclient/greenlight/greenlight.go
Original file line number Diff line number Diff line change
Expand Up @@ -692,3 +692,7 @@ func (gs *GreenlightService) GetSupportedNIP47NotificationTypes() []string {
func (gs *GreenlightService) GetPubkey() string {
return gs.pubkey
}

func (gs *GreenlightService) PayOfferSync(ctx context.Context, offer string, amount uint64) (string, *lnclient.PayOfferResponse, error) {
return "", nil, errors.New("not supported")
}
145 changes: 145 additions & 0 deletions lnclient/ldk/ldk.go
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,14 @@ func NewLDKService(ctx context.Context, cfg config.Config, eventPublisher events
}
}()

offer, err := ls.node.Bolt12Payment().ReceiveVariableAmount("Pay to alby hub")
if err != nil {
logger.Logger.WithError(err).Error("Failed to make Bolt12 offer")
} else {

logger.Logger.WithField("offer", offer).Info("My offer")
}

return &ls, nil
}

Expand Down Expand Up @@ -409,6 +417,122 @@ func (ls *LDKService) resetRouterInternal() {
}
}

// TODO: make amount optional
// TODO: payer note
func (ls *LDKService) PayOfferSync(ctx context.Context, offer string, amount uint64) (string, *lnclient.PayOfferResponse, error) {
payerNote := "Hello from Alby Hub"

// TODO: send liquidity event if amount too large

paymentStart := time.Now()
ldkEventSubscription := ls.ldkEventBroadcaster.Subscribe()
defer ls.ldkEventBroadcaster.CancelSubscription(ldkEventSubscription)

paymentId, err := ls.node.Bolt12Payment().SendUsingAmount(offer, &payerNote, amount)
if err != nil {
logger.Logger.WithError(err).Error("Failed to initiate BOLT12 variable amount payment")
}

fee := uint64(0)
preimage := ""

payment := ls.node.Payment(paymentId)
if payment == nil {
return "", nil, errors.New("payment not found by payment ID")
}

paymentHash := ""

for start := time.Now(); time.Since(start) < time.Second*60; {
event := <-ldkEventSubscription

eventPaymentSuccessful, isEventPaymentSuccessfulEvent := (*event).(ldk_node.EventPaymentSuccessful)
eventPaymentFailed, isEventPaymentFailedEvent := (*event).(ldk_node.EventPaymentFailed)

if isEventPaymentSuccessfulEvent && eventPaymentSuccessful.PaymentId != nil && *eventPaymentSuccessful.PaymentId == paymentId {
logger.Logger.Info("Got payment success event")
payment := ls.node.Payment(paymentId)
if payment == nil {
logger.Logger.Errorf("Couldn't find payment by payment ID: %v", paymentId)
return paymentHash, nil, errors.New("Payment not found")
}

bolt12PaymentKind, ok := payment.Kind.(ldk_node.PaymentKindBolt12Offer)

if !ok {
logger.Logger.WithFields(logrus.Fields{
"payment": payment,
}).Error("Payment is not a bolt12 offer kind")
return paymentHash, nil, errors.New("payment is not a bolt12 offer")
}

if bolt12PaymentKind.Preimage == nil {
logger.Logger.Errorf("No payment preimage for payment ID: %v", paymentId)
return paymentHash, nil, errors.New("payment preimage not found")
}
preimage = *bolt12PaymentKind.Preimage

if bolt12PaymentKind.Hash == nil {
logger.Logger.Errorf("No payment hash for payment ID: %v", paymentId)
return "", nil, errors.New("payment hash not found")
}
paymentHash = *bolt12PaymentKind.Hash

if eventPaymentSuccessful.FeePaidMsat != nil {
fee = *eventPaymentSuccessful.FeePaidMsat
}
break
}
if isEventPaymentFailedEvent && eventPaymentFailed.PaymentId != nil && *eventPaymentFailed.PaymentId == paymentId {
var failureReason ldk_node.PaymentFailureReason
var failureReasonMessage string
if eventPaymentFailed.Reason != nil {
failureReason = *eventPaymentFailed.Reason
}
switch failureReason {
case ldk_node.PaymentFailureReasonRecipientRejected:
failureReasonMessage = "RecipientRejected"
case ldk_node.PaymentFailureReasonUserAbandoned:
failureReasonMessage = "UserAbandoned"
case ldk_node.PaymentFailureReasonRetriesExhausted:
failureReasonMessage = "RetriesExhausted"
case ldk_node.PaymentFailureReasonPaymentExpired:
failureReasonMessage = "PaymentExpired"
case ldk_node.PaymentFailureReasonRouteNotFound:
failureReasonMessage = "RouteNotFound"
case ldk_node.PaymentFailureReasonUnexpectedError:
failureReasonMessage = "UnexpectedError"
default:
failureReasonMessage = "UnknownError"
}

logger.Logger.WithFields(logrus.Fields{
"payment_id": paymentId,
"failure_reason": failureReason,
"failure_reason_message": failureReasonMessage,
}).Error("Received payment failed event")

return paymentHash, nil, fmt.Errorf("received payment failed event: %v %s", failureReason, failureReasonMessage)
}
}
if preimage == "" {
logger.Logger.WithFields(logrus.Fields{
"payment_id": paymentId,
}).Warn("Timed out waiting for payment to be sent")
return paymentHash, nil, lnclient.NewTimeoutError()
}

logger.Logger.WithFields(logrus.Fields{
"duration": time.Since(paymentStart).Milliseconds(),
"fee": fee,
}).Info("Successful payment")

return paymentHash, &lnclient.PayOfferResponse{
Preimage: preimage,
Fee: &fee,
}, nil
}

func (ls *LDKService) SendPaymentSync(ctx context.Context, invoice string) (*lnclient.PayInvoiceResponse, error) {
paymentRequest, err := decodepay.Decodepay(invoice)
if err != nil {
Expand Down Expand Up @@ -464,6 +588,7 @@ func (ls *LDKService) SendPaymentSync(ctx context.Context, invoice string) (*lnc
logger.Logger.WithFields(logrus.Fields{
"payment": payment,
}).Error("Payment is not a bolt11 kind")
return nil, errors.New("payment is not a bolt11 kind")
}

if bolt11PaymentKind.Preimage == nil {
Expand Down Expand Up @@ -1104,6 +1229,26 @@ func (ls *LDKService) ldkPaymentToTransaction(payment *ldk_node.PaymentDetails)
paymentHash = bolt11PaymentKind.Hash
}

bolt12PaymentKind, isBolt12PaymentKind := payment.Kind.(ldk_node.PaymentKindBolt12Offer)

if isBolt12PaymentKind {
logger.Logger.WithField("bolt12", bolt12PaymentKind).WithField("payment", payment).Info("Received Bolt12 payment!")
createdAt = int64(payment.CreatedAt)

paymentHash = *bolt12PaymentKind.Hash
// TODO: get description by decoding offer (how to get the offer from the offer ID?)
//description = paymentRequest.Description
//descriptionHash = paymentRequest.DescriptionHash

// TODO: get payer note from BOLT12 payment (how?)

if payment.Status == ldk_node.PaymentStatusSucceeded {
preimage = *bolt12PaymentKind.Preimage
lastUpdate := int64(payment.LatestUpdateTimestamp)
settledAt = &lastUpdate
}
}

spontaneousPaymentKind, isSpontaneousPaymentKind := payment.Kind.(ldk_node.PaymentKindSpontaneous)
if isSpontaneousPaymentKind {
// keysend payment
Expand Down
4 changes: 4 additions & 0 deletions lnclient/lnd/lnd.go
Original file line number Diff line number Diff line change
Expand Up @@ -916,3 +916,7 @@ func (svc *LNDService) GetSupportedNIP47NotificationTypes() []string {
func (svc *LNDService) GetPubkey() string {
return svc.pubkey
}

func (svc *LNDService) PayOfferSync(ctx context.Context, offer string, amount uint64) (string, *lnclient.PayOfferResponse, error) {
return "", nil, errors.New("not supported")
}
3 changes: 3 additions & 0 deletions lnclient/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ type NodeConnectionInfo struct {

type LNClient interface {
SendPaymentSync(ctx context.Context, payReq string) (*PayInvoiceResponse, error)
PayOfferSync(ctx context.Context, offer string, amount uint64) (string, *PayOfferResponse, error)
SendKeysend(ctx context.Context, amount uint64, destination string, customRecords []TLVRecord) (paymentHash string, preimage string, fee uint64, err error)
GetBalance(ctx context.Context) (balance int64, err error)
GetPubkey() string
Expand Down Expand Up @@ -157,6 +158,8 @@ type PayInvoiceResponse struct {
Fee *uint64 `json:"fee"`
}

type PayOfferResponse = PayInvoiceResponse

type BalancesResponse struct {
Onchain OnchainBalanceResponse `json:"onchain"`
Lightning LightningBalanceResponse `json:"lightning"`
Expand Down
4 changes: 4 additions & 0 deletions lnclient/phoenixd/phoenixd.go
Original file line number Diff line number Diff line change
Expand Up @@ -542,3 +542,7 @@ func (svc *PhoenixService) GetSupportedNIP47NotificationTypes() []string {
func (svc *PhoenixService) GetPubkey() string {
return svc.pubkey
}

func (svc *PhoenixService) PayOfferSync(ctx context.Context, offer string, amount uint64) (string, *lnclient.PayOfferResponse, error) {
return "", nil, errors.New("not supported")
}
2 changes: 1 addition & 1 deletion nip47/controllers/pay_invoice_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ func (controller *nip47Controller) pay(ctx context.Context, bolt11 string, payme
"bolt11": bolt11,
}).Info("Sending payment")

response, err := controller.transactionsService.SendPaymentSync(ctx, bolt11, controller.lnClient, &app.ID, &requestEventId)
response, err := controller.transactionsService.SendPaymentSync(ctx, bolt11, nil, controller.lnClient, &app.ID, &requestEventId)
if err != nil {
logger.Logger.WithFields(logrus.Fields{
"request_event_id": requestEventId,
Expand Down
5 changes: 5 additions & 0 deletions tests/mock_ln_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package tests

import (
"context"
"errors"
"time"

"github.com/getAlby/hub/lnclient"
Expand Down Expand Up @@ -185,3 +186,7 @@ func (mln *MockLn) GetPubkey() string {

return "123pubkey"
}

func (mln *MockLn) PayOfferSync(ctx context.Context, offer string, amount uint64) (string, *lnclient.PayOfferResponse, error) {
return "", nil, errors.New("not supported")
}
Loading
Loading