diff --git a/uma/payee_data.go b/uma/payee_data.go index aebd3b2..80175a0 100644 --- a/uma/payee_data.go +++ b/uma/payee_data.go @@ -32,7 +32,7 @@ type CompliancePayeeData struct { // Utxos is a list of UTXOs of channels over which the receiver will likely receive the payment. Utxos []string `json:"utxos"` // UtxoCallback is the URL that the sender VASP will call to send UTXOs of the channel that the sender used to send the payment once it completes. - UtxoCallback string `json:"utxoCallback"` + UtxoCallback *string `json:"utxoCallback"` // Signature is the base64-encoded signature of sha256(SenderAddress|ReceiverAddress|Nonce|Timestamp). Signature string `json:"signature"` // Nonce is a random string that is used to prevent replay attacks. diff --git a/uma/payer_data.go b/uma/payer_data.go index a941a4b..3102c84 100644 --- a/uma/payer_data.go +++ b/uma/payer_data.go @@ -108,3 +108,16 @@ type CompliancePayerData struct { // UtxoCallback is the URL that the receiver will call to send UTXOs of the channel that the receiver used to receive the payment once it completes. UtxoCallback string `json:"utxoCallback"` } + +func (c *CompliancePayerData) AsMap() (map[string]interface{}, error) { + complianceJson, err := json.Marshal(c) + if err != nil { + return nil, err + } + var complianceMap map[string]interface{} + err = json.Unmarshal(complianceJson, &complianceMap) + if err != nil { + return nil, err + } + return complianceMap, nil +} diff --git a/uma/protocol.go b/uma/protocol.go index 33e6a70..0ae48a3 100644 --- a/uma/protocol.go +++ b/uma/protocol.go @@ -17,18 +17,41 @@ type LnurlpRequest struct { // ReceiverAddress is the address of the user at VASP2 that is receiving the payment. ReceiverAddress string // Nonce is a random string that is used to prevent replay attacks. - Nonce string + Nonce *string // Signature is the base64-encoded signature of sha256(ReceiverAddress|Nonce|Timestamp). - Signature string + Signature *string // IsSubjectToTravelRule indicates VASP1 is a financial institution that requires travel rule information. - IsSubjectToTravelRule bool + IsSubjectToTravelRule *bool // VaspDomain is the domain of the VASP that is sending the payment. It will be used by VASP2 to fetch the public keys of VASP1. - VaspDomain string + VaspDomain *string // Timestamp is the unix timestamp of when the request was sent. Used in the signature. - Timestamp time.Time + Timestamp *time.Time // UmaVersion is the version of the UMA protocol that VASP1 prefers to use for this transaction. For the version // negotiation flow, see https://static.swimlanes.io/87f5d188e080cb8e0494e46f80f2ae74.png - UmaVersion string + UmaVersion *string +} + +// AsUmaRequest returns the request as an UmaLnurlpRequest if it is a valid UMA request, otherwise it returns nil. +// This is useful for validation and avoiding nil pointer dereferences. +func (q *LnurlpRequest) AsUmaRequest() *UmaLnurlpRequest { + if !q.IsUmaRequest() { + return nil + } + return &UmaLnurlpRequest{ + LnurlpRequest: *q, + ReceiverAddress: q.ReceiverAddress, + Nonce: *q.Nonce, + Signature: *q.Signature, + IsSubjectToTravelRule: q.IsSubjectToTravelRule != nil && *q.IsSubjectToTravelRule, + VaspDomain: *q.VaspDomain, + Timestamp: *q.Timestamp, + UmaVersion: *q.UmaVersion, + } +} + +// IsUmaRequest returns true if the request is a valid UMA request, otherwise, if any fields are missing, it returns false. +func (q *LnurlpRequest) IsUmaRequest() bool { + return q.VaspDomain != nil && q.Nonce != nil && q.Signature != nil && q.Timestamp != nil && q.UmaVersion != nil } func (q *LnurlpRequest) EncodeToUrl() (*url.URL, error) { @@ -46,36 +69,73 @@ func (q *LnurlpRequest) EncodeToUrl() (*url.URL, error) { Path: fmt.Sprintf("/.well-known/lnurlp/%s", receiverAddressParts[0]), } queryParams := lnurlpUrl.Query() - queryParams.Add("signature", q.Signature) - queryParams.Add("vaspDomain", q.VaspDomain) - queryParams.Add("nonce", q.Nonce) - queryParams.Add("isSubjectToTravelRule", strconv.FormatBool(q.IsSubjectToTravelRule)) - queryParams.Add("timestamp", strconv.FormatInt(q.Timestamp.Unix(), 10)) - queryParams.Add("umaVersion", q.UmaVersion) + if q.IsUmaRequest() { + queryParams.Add("signature", *q.Signature) + queryParams.Add("vaspDomain", *q.VaspDomain) + queryParams.Add("nonce", *q.Nonce) + isSubjectToTravelRule := *q.IsSubjectToTravelRule + queryParams.Add("isSubjectToTravelRule", strconv.FormatBool(isSubjectToTravelRule)) + queryParams.Add("timestamp", strconv.FormatInt(q.Timestamp.Unix(), 10)) + queryParams.Add("umaVersion", *q.UmaVersion) + } lnurlpUrl.RawQuery = queryParams.Encode() return &lnurlpUrl, nil } -func (q *LnurlpRequest) signablePayload() []byte { - payloadString := strings.Join([]string{q.ReceiverAddress, q.Nonce, strconv.FormatInt(q.Timestamp.Unix(), 10)}, "|") - return []byte(payloadString) +// UmaLnurlpRequest is the first request in the UMA protocol. +// It is sent by the VASP that is sending the payment to find out information about the receiver. +type UmaLnurlpRequest struct { + LnurlpRequest + // ReceiverAddress is the address of the user at VASP2 that is receiving the payment. + ReceiverAddress string + // Nonce is a random string that is used to prevent replay attacks. + Nonce string + // Signature is the base64-encoded signature of sha256(ReceiverAddress|Nonce|Timestamp). + Signature string + // IsSubjectToTravelRule indicates VASP1 is a financial institution that requires travel rule information. + IsSubjectToTravelRule bool + // VaspDomain is the domain of the VASP that is sending the payment. It will be used by VASP2 to fetch the public keys of VASP1. + VaspDomain string + // Timestamp is the unix timestamp of when the request was sent. Used in the signature. + Timestamp time.Time + // UmaVersion is the version of the UMA protocol that VASP1 prefers to use for this transaction. For the version + // negotiation flow, see https://static.swimlanes.io/87f5d188e080cb8e0494e46f80f2ae74.png + UmaVersion string +} + +func (q *LnurlpRequest) signablePayload() ([]byte, error) { + if q.Timestamp == nil || q.Nonce == nil { + return nil, errors.New("timestamp and nonce are required for signing") + } + payloadString := strings.Join([]string{q.ReceiverAddress, *q.Nonce, strconv.FormatInt(q.Timestamp.Unix(), 10)}, "|") + return []byte(payloadString), nil } // LnurlpResponse is the response to the LnurlpRequest. // It is sent by the VASP that is receiving the payment to provide information to the sender about the receiver. type LnurlpResponse struct { - Tag string `json:"tag"` - Callback string `json:"callback"` - MinSendable int64 `json:"minSendable"` - MaxSendable int64 `json:"maxSendable"` - EncodedMetadata string `json:"metadata"` - Currencies []Currency `json:"currencies"` - RequiredPayerData CounterPartyDataOptions `json:"payerData"` - Compliance LnurlComplianceResponse `json:"compliance"` + Tag string `json:"tag"` + Callback string `json:"callback"` + MinSendable int64 `json:"minSendable"` + MaxSendable int64 `json:"maxSendable"` + EncodedMetadata string `json:"metadata"` + // Currencies is the list of currencies that the receiver can quote. See LUD-21. Required for UMA. + Currencies *[]Currency `json:"currencies"` + // RequiredPayerData the data about the payer that the sending VASP must provide in order to send a payment. + RequiredPayerData *CounterPartyDataOptions `json:"payerData"` + // Compliance is compliance-related data from the receiving VASP for UMA. + Compliance *LnurlComplianceResponse `json:"compliance"` // UmaVersion is the version of the UMA protocol that VASP2 has chosen for this transaction based on its own support // and VASP1's specified preference in the LnurlpRequest. For the version negotiation flow, see // https://static.swimlanes.io/87f5d188e080cb8e0494e46f80f2ae74.png - UmaVersion string `json:"umaVersion"` + UmaVersion *string `json:"umaVersion"` + // CommentCharsAllowed is the number of characters that the sender can include in the comment field of the pay request. + CommentCharsAllowed *int `json:"commentAllowed"` + // NostrPubkey is an optional nostr pubkey used for nostr zaps (NIP-57). If set, it should be a valid BIP-340 public + // key in hex format. + NostrPubkey *string `json:"nostrPubkey"` + // AllowsNostr should be set to true if the receiving VASP allows nostr zaps (NIP-57). + AllowsNostr *bool `json:"allowsNostr"` } // LnurlComplianceResponse is the `compliance` field of the LnurlpResponse. @@ -94,7 +154,50 @@ type LnurlComplianceResponse struct { ReceiverIdentifier string `json:"receiverIdentifier"` } -func (r *LnurlpResponse) signablePayload() []byte { +func (r *LnurlpResponse) IsUmaResponse() bool { + return r.Compliance != nil && r.UmaVersion != nil && r.Currencies != nil && r.RequiredPayerData != nil +} + +func (r *LnurlpResponse) AsUmaResponse() *UmaLnurlpResponse { + if !r.IsUmaResponse() { + return nil + } + return &UmaLnurlpResponse{ + LnurlpResponse: *r, + Currencies: *r.Currencies, + RequiredPayerData: *r.RequiredPayerData, + Compliance: *r.Compliance, + UmaVersion: *r.UmaVersion, + CommentCharsAllowed: r.CommentCharsAllowed, + NostrPubkey: r.NostrPubkey, + AllowsNostr: r.AllowsNostr, + } +} + +// UmaLnurlpResponse is the UMA response to the LnurlpRequest. +// It is sent by the VASP that is receiving the payment to provide information to the sender about the receiver. +type UmaLnurlpResponse struct { + LnurlpResponse + // Currencies is the list of currencies that the receiver can quote. See LUD-21. Required for UMA. + Currencies []Currency `json:"currencies"` + // RequiredPayerData the data about the payer that the sending VASP must provide in order to send a payment. + RequiredPayerData CounterPartyDataOptions `json:"payerData"` + // Compliance is compliance-related data from the receiving VASP for UMA. + Compliance LnurlComplianceResponse `json:"compliance"` + // UmaVersion is the version of the UMA protocol that VASP2 has chosen for this transaction based on its own support + // and VASP1's specified preference in the LnurlpRequest. For the version negotiation flow, see + // https://static.swimlanes.io/87f5d188e080cb8e0494e46f80f2ae74.png + UmaVersion string `json:"umaVersion"` + // CommentCharsAllowed is the number of characters that the sender can include in the comment field of the pay request. + CommentCharsAllowed *int `json:"commentAllowed"` + // NostrPubkey is an optional nostr pubkey used for nostr zaps (NIP-57). If set, it should be a valid BIP-340 public + // key in hex format. + NostrPubkey *string `json:"nostrPubkey"` + // AllowsNostr should be set to true if the receiving VASP allows nostr zaps (NIP-57). + AllowsNostr *bool `json:"allowsNostr"` +} + +func (r *UmaLnurlpResponse) signablePayload() []byte { payloadString := strings.Join([]string{ r.Compliance.ReceiverIdentifier, r.Compliance.Nonce, @@ -124,16 +227,36 @@ type PayRequest struct { // Rather, by specifying an invoice amount in msats, the sending VASP can ensure that their // user will be sending a fixed amount, regardless of the exchange rate on the receiving side. SendingAmountCurrencyCode *string `json:"sendingAmountCurrencyCode"` - // ReceivingCurrencyCode is the ISO 3-digit currency code that the receiver will receive for this payment. - ReceivingCurrencyCode string `json:"convert"` + // ReceivingCurrencyCode is the ISO 3-digit currency code that the receiver will receive for this payment. Defaults + // to amount being specified in msats if this is not provided. + ReceivingCurrencyCode *string `json:"convert"` // Amount is the amount that the receiver will receive for this payment in the smallest unit of the specified // currency (i.e. cents for USD) if `SendingAmountCurrencyCode` is not `nil`. Otherwise, it is the amount in // millisatoshis. Amount int64 `json:"amount"` - // PayerData is the data that the sender will send to the receiver to identify themselves. - PayerData PayerData `json:"payerData"` + // PayerData is the data that the sender will send to the receiver to identify themselves. Required for UMA, as is + // the `compliance` field in the `payerData` object. + PayerData *PayerData `json:"payerData"` // RequestedPayeeData is the data that the sender is requesting about the payee. RequestedPayeeData *CounterPartyDataOptions `json:"payeeData"` + // Comment is a comment that the sender would like to include with the payment. This can only be included + // if the receiver included the `commentAllowed` field in the lnurlp response. The length of + // the comment must be less than or equal to the value of `commentAllowed`. + Comment *string `json:"comment"` +} + +// IsUmaRequest returns true if the request is a valid UMA request, otherwise, if any fields are missing, it returns false. +func (p *PayRequest) IsUmaRequest() bool { + if p.PayerData == nil { + return false + } + + compliance, err := p.PayerData.Compliance() + if err != nil { + return false + } + + return compliance != nil && p.PayerData.Identifier() != nil } func (p *PayRequest) MarshalJSON() ([]byte, error) { @@ -141,14 +264,24 @@ func (p *PayRequest) MarshalJSON() ([]byte, error) { if p.SendingAmountCurrencyCode != nil { amount = fmt.Sprintf("%s.%s", amount, *p.SendingAmountCurrencyCode) } - payerDataJson, err := json.Marshal(p.PayerData) - if err != nil { - return nil, err + var payerDataJson []byte + if p.PayerData != nil { + var err error + payerDataJson, err = json.Marshal(p.PayerData) + if err != nil { + return nil, err + } } reqStr := fmt.Sprintf(`{ - "convert": "%s", - "amount": "%s", - "payerData": %s`, p.ReceivingCurrencyCode, amount, payerDataJson) + "amount": "%s"`, amount) + if p.ReceivingCurrencyCode != nil { + reqStr += fmt.Sprintf(`, + "convert": "%s"`, *p.ReceivingCurrencyCode) + } + if p.PayerData != nil { + reqStr += fmt.Sprintf(`, + "payerData": %s`, payerDataJson) + } if p.RequestedPayeeData != nil { payeeDataJson, err := json.Marshal(p.RequestedPayeeData) if err != nil { @@ -157,6 +290,10 @@ func (p *PayRequest) MarshalJSON() ([]byte, error) { reqStr += fmt.Sprintf(`, "payeeData": %s`, payeeDataJson) } + if p.Comment != nil { + reqStr += fmt.Sprintf(`, + "comment": "%s"`, *p.Comment) + } reqStr += "}" return []byte(reqStr), nil } @@ -168,10 +305,9 @@ func (p *PayRequest) UnmarshalJSON(data []byte) error { return err } convert, ok := rawReq["convert"].(string) - if !ok { - return errors.New("missing or invalid convert field") + if ok { + p.ReceivingCurrencyCode = &convert } - p.ReceivingCurrencyCode = convert amount, ok := rawReq["amount"].(string) if !ok { return errors.New("missing or invalid amount field") @@ -188,16 +324,17 @@ func (p *PayRequest) UnmarshalJSON(data []byte) error { p.SendingAmountCurrencyCode = &amountParts[1] } payerDataJson, ok := rawReq["payerData"].(map[string]interface{}) - if !ok { - return errors.New("missing or invalid payerData field") - } - payerDataJsonBytes, err := json.Marshal(payerDataJson) - if err != nil { - return err - } - err = json.Unmarshal(payerDataJsonBytes, &p.PayerData) - if err != nil { - return err + if ok { + payerDataJsonBytes, err := json.Marshal(payerDataJson) + if err != nil { + return err + } + var payerData PayerData + err = json.Unmarshal(payerDataJsonBytes, &payerData) + if err != nil { + return err + } + p.PayerData = &payerData } payeeDataJson, ok := rawReq["payeeData"].(map[string]interface{}) if ok { @@ -212,24 +349,57 @@ func (p *PayRequest) UnmarshalJSON(data []byte) error { } p.RequestedPayeeData = &payeeData } + comment, ok := rawReq["comment"].(string) + if ok { + p.Comment = &comment + } return nil } -func (q *PayRequest) Encode() ([]byte, error) { - return json.Marshal(q) +func (p *PayRequest) Encode() ([]byte, error) { + return json.Marshal(p) } -func (q *PayRequest) signablePayload() ([]byte, error) { - senderAddress := q.PayerData.Identifier() - if senderAddress == nil || *senderAddress == "" { +func (p *PayRequest) EncodeAsUrlParams() (*url.Values, error) { + jsonBytes, err := json.Marshal(p) + if err != nil { + return nil, err + } + jsonMap := make(map[string]interface{}) + err = json.Unmarshal(jsonBytes, &jsonMap) + if err != nil { + return nil, err + } + payReqParams := url.Values{} + for key, value := range jsonMap { + valueString, ok := value.(string) + if ok { + payReqParams.Add(key, valueString) + } else { + valueBytes, err := json.Marshal(value) + if err != nil { + return nil, err + } + payReqParams.Add(key, string(valueBytes)) + } + } + return &payReqParams, nil +} + +func (p *PayRequest) signablePayload() ([]byte, error) { + if p.PayerData == nil { return nil, errors.New("payer data is missing") } - complianceData, err := q.PayerData.Compliance() + senderAddress := p.PayerData.Identifier() + if senderAddress == nil || *senderAddress == "" { + return nil, errors.New("payer data identifier is missing") + } + complianceData, err := p.PayerData.Compliance() if err != nil { return nil, err } if complianceData == nil { - return nil, errors.New("compliance data is missing") + return nil, errors.New("compliance payer data is missing") } signatureNonce := complianceData.SignatureNonce signatureTimestamp := complianceData.SignatureTimestamp @@ -246,9 +416,31 @@ type PayReqResponse struct { // EncodedInvoice is the BOLT11 invoice that the sender will pay. EncodedInvoice string `json:"pr"` // Routes is usually just an empty list from legacy LNURL, which was replaced by route hints in the BOLT11 invoice. - Routes []Route `json:"routes"` - PaymentInfo PayReqResponsePaymentInfo `json:"paymentInfo"` - PayeeData *PayeeData `json:"payeeData"` + Routes []Route `json:"routes"` + // PaymentInfo is information about the payment that the receiver will receive. Includes Final currency-related + // information for the payment. Required for UMA. + PaymentInfo *PayReqResponsePaymentInfo `json:"paymentInfo"` + // PayeeData The data about the receiver that the sending VASP requested in the payreq request. + // Required for UMA. + PayeeData *PayeeData `json:"payeeData"` + // Disposable This field may be used by a WALLET to decide whether the initial LNURL link will be stored locally + // for later reuse or erased. If disposable is null, it should be interpreted as true, so if SERVICE intends its + // LNURL links to be stored it must return `disposable: false`. UMA should never return `disposable: false` due to + // signature nonce checks, etc. See LUD-11. + Disposable *bool `json:"disposable"` + // SuccessAction defines a struct which can be stored and shown to the user on payment success. See LUD-09. + SuccessAction *map[string]string `json:"successAction"` +} + +func (p *PayReqResponse) IsUmaResponse() bool { + if p.PaymentInfo == nil || p.PayeeData == nil { + return false + } + compliance, err := p.PayeeData.Compliance() + if err != nil { + return false + } + return compliance != nil } type Route struct { diff --git a/uma/test/uma_test.go b/uma/test/uma_test.go index 0411b07..8b67206 100644 --- a/uma/test/uma_test.go +++ b/uma/test/uma_test.go @@ -18,16 +18,21 @@ import ( func TestParse(t *testing.T) { expectedTime, _ := time.Parse(time.RFC3339, "2023-07-27T22:46:08Z") timeSec := expectedTime.Unix() + signature := "signature" + isSubjectToTravelRule := true + nonce := "12345" + vaspDomain := "vasp1.com" + umaVersion := "1.0" expectedQuery := uma.LnurlpRequest{ ReceiverAddress: "bob@vasp2.com", - Signature: "signature", - IsSubjectToTravelRule: true, - Nonce: "12345", - Timestamp: expectedTime, - VaspDomain: "vasp1.com", - UmaVersion: "0.1", + Signature: &signature, + IsSubjectToTravelRule: &isSubjectToTravelRule, + Nonce: &nonce, + Timestamp: &expectedTime, + VaspDomain: &vaspDomain, + UmaVersion: &umaVersion, } - urlString := "https://vasp2.com/.well-known/lnurlp/bob?signature=signature&nonce=12345&vaspDomain=vasp1.com&umaVersion=0.1&isSubjectToTravelRule=true×tamp=" + strconv.FormatInt(timeSec, 10) + urlString := "https://vasp2.com/.well-known/lnurlp/bob?signature=signature&nonce=12345&vaspDomain=vasp1.com&umaVersion=1.0&isSubjectToTravelRule=true×tamp=" + strconv.FormatInt(timeSec, 10) urlObj, _ := url.Parse(urlString) query, err := uma.ParseLnurlpRequest(*urlObj) if err != nil || query == nil { @@ -37,13 +42,13 @@ func TestParse(t *testing.T) { } func TestIsUmaQueryValid(t *testing.T) { - urlString := "https://vasp2.com/.well-known/lnurlp/bob?signature=signature&nonce=12345&vaspDomain=vasp1.com&umaVersion=0.1&isSubjectToTravelRule=true×tamp=12345678" + urlString := "https://vasp2.com/.well-known/lnurlp/bob?signature=signature&nonce=12345&vaspDomain=vasp1.com&umaVersion=1.0&isSubjectToTravelRule=true×tamp=12345678" urlObj, _ := url.Parse(urlString) assert.True(t, uma.IsUmaLnurlpQuery(*urlObj)) } func TestIsUmaQueryMissingParams(t *testing.T) { - urlString := "https://vasp2.com/.well-known/lnurlp/bob?nonce=12345&vaspDomain=vasp1.com&umaVersion=0.1&isSubjectToTravelRule=true×tamp=12345678" + urlString := "https://vasp2.com/.well-known/lnurlp/bob?nonce=12345&vaspDomain=vasp1.com&umaVersion=1.0&isSubjectToTravelRule=true×tamp=12345678" urlObj, _ := url.Parse(urlString) assert.False(t, uma.IsUmaLnurlpQuery(*urlObj)) @@ -51,20 +56,20 @@ func TestIsUmaQueryMissingParams(t *testing.T) { urlObj, _ = url.Parse(urlString) assert.False(t, uma.IsUmaLnurlpQuery(*urlObj)) - urlString = "https://vasp2.com/.well-known/lnurlp/bob?signature=signature&vaspDomain=vasp1.com&umaVersion=0.1&isSubjectToTravelRule=true×tamp=12345678" + urlString = "https://vasp2.com/.well-known/lnurlp/bob?signature=signature&vaspDomain=vasp1.com&umaVersion=1.0&isSubjectToTravelRule=true×tamp=12345678" urlObj, _ = url.Parse(urlString) assert.False(t, uma.IsUmaLnurlpQuery(*urlObj)) - urlString = "https://vasp2.com/.well-known/lnurlp/bob?signature=signature&umaVersion=0.1&nonce=12345&isSubjectToTravelRule=true×tamp=12345678" + urlString = "https://vasp2.com/.well-known/lnurlp/bob?signature=signature&umaVersion=1.0&nonce=12345&isSubjectToTravelRule=true×tamp=12345678" urlObj, _ = url.Parse(urlString) assert.False(t, uma.IsUmaLnurlpQuery(*urlObj)) - urlString = "https://vasp2.com/.well-known/lnurlp/bob?signature=signature&umaVersion=0.1&nonce=12345&vaspDomain=vasp1.com×tamp=12345678" + urlString = "https://vasp2.com/.well-known/lnurlp/bob?signature=signature&umaVersion=1.0&nonce=12345&vaspDomain=vasp1.com×tamp=12345678" urlObj, _ = url.Parse(urlString) // IsSubjectToTravelRule is optional assert.True(t, uma.IsUmaLnurlpQuery(*urlObj)) - urlString = "https://vasp2.com/.well-known/lnurlp/bob?signature=signature&nonce=12345&vaspDomain=vasp1.com&umaVersion=0.1&isSubjectToTravelRule=true" + urlString = "https://vasp2.com/.well-known/lnurlp/bob?signature=signature&nonce=12345&vaspDomain=vasp1.com&umaVersion=1.0&isSubjectToTravelRule=true" urlObj, _ = url.Parse(urlString) assert.False(t, uma.IsUmaLnurlpQuery(*urlObj)) @@ -74,15 +79,15 @@ func TestIsUmaQueryMissingParams(t *testing.T) { } func TestIsUmaQueryInvalidPath(t *testing.T) { - urlString := "https://vasp2.com/.well-known/lnurla/bob?signature=signature&nonce=12345&vaspDomain=vasp1.com&umaVersion=0.1&isSubjectToTravelRule=true×tamp=12345678" + urlString := "https://vasp2.com/.well-known/lnurla/bob?signature=signature&nonce=12345&vaspDomain=vasp1.com&umaVersion=1.0&isSubjectToTravelRule=true×tamp=12345678" urlObj, _ := url.Parse(urlString) assert.False(t, uma.IsUmaLnurlpQuery(*urlObj)) - urlString = "https://vasp2.com/bob?signature=signature&nonce=12345&vaspDomain=vasp1.com&umaVersion=0.1&isSubjectToTravelRule=true×tamp=12345678" + urlString = "https://vasp2.com/bob?signature=signature&nonce=12345&vaspDomain=vasp1.com&umaVersion=1.0&isSubjectToTravelRule=true×tamp=12345678" urlObj, _ = url.Parse(urlString) assert.False(t, uma.IsUmaLnurlpQuery(*urlObj)) - urlString = "https://vasp2.com/?signature=signature&nonce=12345&vaspDomain=vasp1.com&umaVersion=0.1&isSubjectToTravelRule=true×tamp=12345678" + urlString = "https://vasp2.com/?signature=signature&nonce=12345&vaspDomain=vasp1.com&umaVersion=1.0&isSubjectToTravelRule=true×tamp=12345678" urlObj, _ = url.Parse(urlString) assert.False(t, uma.IsUmaLnurlpQuery(*urlObj)) } @@ -94,8 +99,8 @@ func TestSignAndVerifyLnurlpRequest(t *testing.T) { require.NoError(t, err) query, err := uma.ParseLnurlpRequest(*queryUrl) require.NoError(t, err) - assert.Equal(t, query.UmaVersion, uma.UmaProtocolVersion) - err = uma.VerifyUmaLnurlpQuerySignature(query, privateKey.PubKey().SerializeUncompressed(), getNonceCache()) + assert.Equal(t, *query.UmaVersion, uma.UmaProtocolVersion) + err = uma.VerifyUmaLnurlpQuerySignature(*query.AsUmaRequest(), privateKey.PubKey().SerializeUncompressed(), getNonceCache()) require.NoError(t, err) } @@ -106,7 +111,7 @@ func TestSignAndVerifyLnurlpRequestInvalidSignature(t *testing.T) { require.NoError(t, err) query, err := uma.ParseLnurlpRequest(*queryUrl) require.NoError(t, err) - err = uma.VerifyUmaLnurlpQuerySignature(query, []byte("invalid pub key"), getNonceCache()) + err = uma.VerifyUmaLnurlpQuerySignature(*query.AsUmaRequest(), []byte("invalid pub key"), getNonceCache()) require.Error(t, err) } @@ -118,7 +123,7 @@ func TestSignAndVerifyLnurlpRequestOldSignature(t *testing.T) { query, err := uma.ParseLnurlpRequest(*queryUrl) require.NoError(t, err) tomorrow := time.Now().AddDate(0, 0, 1) - err = uma.VerifyUmaLnurlpQuerySignature(query, privateKey.PubKey().SerializeUncompressed(), uma.NewInMemoryNonceCache(tomorrow)) + err = uma.VerifyUmaLnurlpQuerySignature(*query.AsUmaRequest(), privateKey.PubKey().SerializeUncompressed(), uma.NewInMemoryNonceCache(tomorrow)) require.Error(t, err) } @@ -130,9 +135,9 @@ func TestSignAndVerifyLnurlpRequestDuplicateNonce(t *testing.T) { query, err := uma.ParseLnurlpRequest(*queryUrl) require.NoError(t, err) nonceCache := getNonceCache() - err = nonceCache.CheckAndSaveNonce(query.Nonce, query.Timestamp) + err = nonceCache.CheckAndSaveNonce(query.AsUmaRequest().Nonce, query.AsUmaRequest().Timestamp) require.NoError(t, err) - err = uma.VerifyUmaLnurlpQuerySignature(query, privateKey.PubKey().SerializeUncompressed(), nonceCache) + err = uma.VerifyUmaLnurlpQuerySignature(*query.AsUmaRequest(), privateKey.PubKey().SerializeUncompressed(), nonceCache) require.Error(t, err) } @@ -141,23 +146,26 @@ func TestSignAndVerifyLnurlpResponse(t *testing.T) { require.NoError(t, err) receiverSigningPrivateKey, err := secp256k1.GeneratePrivateKey() require.NoError(t, err) + serializedPrivateKey := receiverSigningPrivateKey.Serialize() request := createLnurlpRequest(t, senderSigningPrivateKey.Serialize()) metadata, err := createMetadataForBob() require.NoError(t, err) + isSubjectToTravelRule := true + kycStatus := uma.KycStatusVerified response, err := uma.GetLnurlpResponse( request, - receiverSigningPrivateKey.Serialize(), - true, "https://vasp2.com/api/lnurl/payreq/$bob", metadata, 1, 10_000_000, - uma.CounterPartyDataOptions{ + &serializedPrivateKey, + &isSubjectToTravelRule, + &uma.CounterPartyDataOptions{ "name": uma.CounterPartyDataOption{Mandatory: false}, "email": uma.CounterPartyDataOption{Mandatory: false}, "compliance": uma.CounterPartyDataOption{Mandatory: true}, }, - []uma.Currency{ + &[]uma.Currency{ { Code: "USD", Name: "US Dollar", @@ -170,7 +178,9 @@ func TestSignAndVerifyLnurlpResponse(t *testing.T) { Decimals: 2, }, }, - uma.KycStatusVerified, + &kycStatus, + nil, + nil, ) require.NoError(t, err) responseJson, err := json.Marshal(response) @@ -178,7 +188,7 @@ func TestSignAndVerifyLnurlpResponse(t *testing.T) { response, err = uma.ParseLnurlpResponse(responseJson) require.NoError(t, err) - err = uma.VerifyUmaLnurlpResponseSignature(response, receiverSigningPrivateKey.PubKey().SerializeUncompressed(), getNonceCache()) + err = uma.VerifyUmaLnurlpResponseSignature(*response.AsUmaResponse(), receiverSigningPrivateKey.PubKey().SerializeUncompressed(), getNonceCache()) require.NoError(t, err) } @@ -194,12 +204,12 @@ func TestPayReqCreationAndParsing(t *testing.T) { Type: "IVMS", Version: &ivmsVersion, } - payreq, err := uma.GetPayRequest( + payreq, err := uma.GetUmaPayRequest( + 1000, receiverEncryptionPrivateKey.PubKey().SerializeUncompressed(), senderSigningPrivateKey.Serialize(), "USD", true, - 1000, "$alice@vasp1.com", nil, nil, @@ -210,6 +220,7 @@ func TestPayReqCreationAndParsing(t *testing.T) { nil, "/api/lnurl/utxocallback?txid=1234", nil, + nil, ) require.NoError(t, err) @@ -256,12 +267,12 @@ func TestMsatsPayReqCreationAndParsing(t *testing.T) { Type: "IVMS", Version: &ivmsVersion, } - payreq, err := uma.GetPayRequest( + payreq, err := uma.GetUmaPayRequest( + 1000, receiverEncryptionPrivateKey.PubKey().SerializeUncompressed(), senderSigningPrivateKey.Serialize(), "USD", false, - 1000, "$alice@vasp1.com", nil, nil, @@ -272,6 +283,7 @@ func TestMsatsPayReqCreationAndParsing(t *testing.T) { nil, "/api/lnurl/utxocallback?txid=1234", nil, + nil, ) require.NoError(t, err) @@ -292,7 +304,7 @@ func TestMsatsPayReqCreationAndParsing(t *testing.T) { type FakeInvoiceCreator struct{} -func (f *FakeInvoiceCreator) CreateUmaInvoice(int64, string) (*string, error) { +func (f *FakeInvoiceCreator) CreateInvoice(int64, string) (*string, error) { encodedInvoice := "lnbcrt100n1p0z9j" return &encodedInvoice, nil } @@ -314,12 +326,12 @@ func TestPayReqResponseAndParsing(t *testing.T) { Mandatory: false, }, } - payreq, err := uma.GetPayRequest( + payreq, err := uma.GetUmaPayRequest( + 1000, receiverEncryptionPrivateKey.PubKey().SerializeUncompressed(), senderSigningPrivateKey.Serialize(), "USD", true, - 1000, "$alice@vasp1.com", nil, nil, @@ -330,6 +342,7 @@ func TestPayReqResponseAndParsing(t *testing.T) { nil, "/api/lnurl/utxocallback?txid=1234", &payeeOptions, + nil, ) require.NoError(t, err) client := &FakeInvoiceCreator{} @@ -338,31 +351,44 @@ func TestPayReqResponseAndParsing(t *testing.T) { payeeData := uma.PayeeData{ "identifier": "$bob@vasp2.com", } + receivingCurrencyCode := "USD" + receivingCurrencyDecimals := 2 + conversionRate := float64(24_150) + fee := int64(100_000) + serializedPrivateKey := receiverSigningPrivateKey.Serialize() + payeeIdentifier := "$bob@vasp2.com" + utxoCallback := "/api/lnurl/utxocallback?txid=1234" payreqResponse, err := uma.GetPayReqResponse( - payreq, + *payreq, client, metadata, - "USD", - 2, - 24_150, - 100_000, - []string{"abcdef12345"}, + &receivingCurrencyCode, + &receivingCurrencyDecimals, + &conversionRate, + &fee, + &[]string{"abcdef12345"}, nil, - "/api/lnurl/utxocallback?txid=1234", + &utxoCallback, &payeeData, - receiverSigningPrivateKey.Serialize(), - "$bob@vasp2.com", + &serializedPrivateKey, + &payeeIdentifier, + nil, + nil, ) require.NoError(t, err) require.Equal(t, payreqResponse.PaymentInfo.Amount, payreq.Amount) - require.Equal(t, payreqResponse.PaymentInfo.CurrencyCode, payreq.ReceivingCurrencyCode) + require.Equal(t, payreqResponse.PaymentInfo.CurrencyCode, *payreq.ReceivingCurrencyCode) payreqResponseJson, err := json.Marshal(payreqResponse) require.NoError(t, err) parsedResponse, err := uma.ParsePayReqResponse(payreqResponseJson) require.NoError(t, err) - require.Equal(t, payreqResponse, parsedResponse) + originalComplianceData, err := payreqResponse.PayeeData.Compliance() + require.NoError(t, err) + parsedComplianceData, err := parsedResponse.PayeeData.Compliance() + require.NoError(t, err) + require.Equal(t, originalComplianceData, parsedComplianceData) err = uma.VerifyPayReqResponseSignature( parsedResponse, @@ -391,12 +417,12 @@ func TestMsatsPayReqResponseAndParsing(t *testing.T) { Mandatory: false, }, } - payreq, err := uma.GetPayRequest( + payreq, err := uma.GetUmaPayRequest( + 1_000_000, receiverEncryptionPrivateKey.PubKey().SerializeUncompressed(), senderSigningPrivateKey.Serialize(), "USD", false, - 1_000_000, "$alice@vasp1.com", nil, nil, @@ -407,6 +433,7 @@ func TestMsatsPayReqResponseAndParsing(t *testing.T) { nil, "/api/lnurl/utxocallback?txid=1234", &payeeOptions, + nil, ) require.NoError(t, err) client := &FakeInvoiceCreator{} @@ -415,27 +442,34 @@ func TestMsatsPayReqResponseAndParsing(t *testing.T) { payeeData := uma.PayeeData{ "identifier": "$bob@vasp2.com", } + receivingCurrencyCode := "USD" + receivingCurrencyDecimals := 2 fee := int64(100_000) conversionRate := float64(24_150) + utxoCallback := "/api/lnurl/utxocallback?txid=1234" + payeeIdentifier := "$bob@vasp2.com" + serializedPrivateKey := receiverSigningPrivateKey.Serialize() payreqResponse, err := uma.GetPayReqResponse( - payreq, + *payreq, client, metadata, - "USD", - 2, - conversionRate, - fee, - []string{"abcdef12345"}, + &receivingCurrencyCode, + &receivingCurrencyDecimals, + &conversionRate, + &fee, + &[]string{"abcdef12345"}, nil, - "/api/lnurl/utxocallback?txid=1234", + &utxoCallback, &payeeData, - receiverSigningPrivateKey.Serialize(), - "$bob@vasp2.com", + &serializedPrivateKey, + &payeeIdentifier, + nil, + nil, ) require.NoError(t, err) expectedAmount := int64(math.Round(float64(payreq.Amount-fee) / conversionRate)) require.Equal(t, payreqResponse.PaymentInfo.Amount, expectedAmount) - require.Equal(t, payreqResponse.PaymentInfo.CurrencyCode, payreq.ReceivingCurrencyCode) + require.Equal(t, payreqResponse.PaymentInfo.CurrencyCode, *payreq.ReceivingCurrencyCode) payreqResponseJson, err := json.Marshal(payreqResponse) require.NoError(t, err) @@ -471,12 +505,91 @@ func TestSignAndVerifyPostTransactionCallback(t *testing.T) { require.NoError(t, err) } -func createLnurlpRequest(t *testing.T, signingPrivateKey []byte) *uma.LnurlpRequest { +func TestParsePayReqFromQueryParamsNoOptionalFields(t *testing.T) { + amount := "1000" + params := url.Values{ + "amount": {amount}, + } + payreq, err := uma.ParsePayRequestFromQueryParams(params) + require.NoError(t, err) + require.Equal(t, payreq.Amount, int64(1000)) + require.Nil(t, payreq.ReceivingCurrencyCode) +} + +func TestParsePayReqFromQueryParamsAllOptionalFields(t *testing.T) { + amount := "1000.USD" + payerData := uma.PayerData{ + "identifier": "$bob@vasp.com", + } + encodedPayerData, err := json.Marshal(payerData) + require.NoError(t, err) + payeeData := uma.CounterPartyDataOptions{ + "identifier": uma.CounterPartyDataOption{ + Mandatory: true, + }, + "name": uma.CounterPartyDataOption{ + Mandatory: false, + }, + } + encodedPayeeData, err := json.Marshal(payeeData) + require.NoError(t, err) + params := url.Values{ + "amount": {amount}, + "convert": {"USD"}, + "payerData": {string(encodedPayerData)}, + "payeeData": {string(encodedPayeeData)}, + "comment": {"This is a comment"}, + } + payreq, err := uma.ParsePayRequestFromQueryParams(params) + require.NoError(t, err) + require.Equal(t, payreq.Amount, int64(1000)) + require.Equal(t, *payreq.ReceivingCurrencyCode, "USD") + require.Equal(t, *payreq.PayerData, payerData) + require.Equal(t, *payreq.RequestedPayeeData, payeeData) + require.Equal(t, *payreq.Comment, "This is a comment") + require.Equal(t, *payreq.SendingAmountCurrencyCode, "USD") +} + +func TestParseAndEncodePayReqToQueryParams(t *testing.T) { + amount := "1000.USD" + payerData := uma.PayerData{ + "identifier": "$bob@vasp.com", + } + encodedPayerData, err := json.Marshal(payerData) + require.NoError(t, err) + payeeData := uma.CounterPartyDataOptions{ + "identifier": uma.CounterPartyDataOption{ + Mandatory: true, + }, + "name": uma.CounterPartyDataOption{ + Mandatory: false, + }, + } + encodedPayeeData, err := json.Marshal(payeeData) + require.NoError(t, err) + params := url.Values{ + "amount": {amount}, + "convert": {"USD"}, + "payerData": {string(encodedPayerData)}, + "payeeData": {string(encodedPayeeData)}, + "comment": {"This is a comment"}, + } + payreq, err := uma.ParsePayRequestFromQueryParams(params) + require.NoError(t, err) + encodedParams, err := payreq.EncodeAsUrlParams() + require.NoError(t, err) + require.Equal(t, params, *encodedParams) + payreqReparsed, err := uma.ParsePayRequestFromQueryParams(*encodedParams) + require.NoError(t, err) + require.Equal(t, payreq, payreqReparsed) +} + +func createLnurlpRequest(t *testing.T, signingPrivateKey []byte) uma.LnurlpRequest { queryUrl, err := uma.GetSignedLnurlpRequestUrl(signingPrivateKey, "$bob@vasp2.com", "vasp1.com", true, nil) require.NoError(t, err) query, err := uma.ParseLnurlpRequest(*queryUrl) require.NoError(t, err) - return query + return *query } func getNonceCache() uma.NonceCache { diff --git a/uma/uma.go b/uma/uma.go index 3d82b07..0b4f75f 100644 --- a/uma/uma.go +++ b/uma/uma.go @@ -162,7 +162,7 @@ func verifySignature(payload []byte, signature string, otherVaspPubKey []byte) e return nil } -// GetSignedLnurlpRequestUrl Creates a signed uma request URL. +// GetSignedLnurlpRequestUrl Creates a signed uma request URL. Should only be used for UMA requests. // // Args: // @@ -186,19 +186,24 @@ func GetSignedLnurlpRequestUrl( if umaVersionOverride != nil { umaVersion = *umaVersionOverride } + now := time.Now() unsignedRequest := LnurlpRequest{ ReceiverAddress: receiverAddress, - IsSubjectToTravelRule: isSubjectToTravelRule, - VaspDomain: senderVaspDomain, - Timestamp: time.Now(), - Nonce: *nonce, - UmaVersion: umaVersion, + IsSubjectToTravelRule: &isSubjectToTravelRule, + VaspDomain: &senderVaspDomain, + Timestamp: &now, + Nonce: nonce, + UmaVersion: &umaVersion, + } + signablePayload, err := unsignedRequest.signablePayload() + if err != nil { + return nil, err } - signature, err := signPayload(unsignedRequest.signablePayload(), signingPrivateKey) + signature, err := signPayload(signablePayload, signingPrivateKey) if err != nil { return nil, err } - unsignedRequest.Signature = *signature + unsignedRequest.Signature = signature return unsignedRequest.EncodeToUrl() } @@ -207,7 +212,7 @@ func GetSignedLnurlpRequestUrl( // You should try to process the request as a regular LNURLp request to fall back to LNURL-PAY. func IsUmaLnurlpQuery(url url.URL) bool { query, err := ParseLnurlpRequest(url) - return err == nil && query != nil + return err == nil && query != nil && query.IsUmaRequest() } // ParseLnurlpRequest Parse Parses the message into an LnurlpRequest object. @@ -219,14 +224,18 @@ func ParseLnurlpRequest(url url.URL) (*LnurlpRequest, error) { signature := query.Get("signature") vaspDomain := query.Get("vaspDomain") nonce := query.Get("nonce") - isSubjectToTravelRule := query.Get("isSubjectToTravelRule") + isSubjectToTravelRule := strings.ToLower(query.Get("isSubjectToTravelRule")) == "true" umaVersion := query.Get("umaVersion") timestamp := query.Get("timestamp") - timestampAsString, dateErr := strconv.ParseInt(timestamp, 10, 64) - if dateErr != nil { - return nil, errors.New("invalid timestamp") + var timestampAsTime *time.Time + if timestamp != "" { + timestampAsString, dateErr := strconv.ParseInt(timestamp, 10, 64) + if dateErr != nil { + return nil, errors.New("invalid timestamp") + } + timestampAsTimeVal := time.Unix(timestampAsString, 0) + timestampAsTime = ×tampAsTimeVal } - timestampAsTime := time.Unix(timestampAsString, 0) if vaspDomain == "" || signature == "" || nonce == "" || timestamp == "" || umaVersion == "" { return nil, errors.New("missing uma query parameters. vaspDomain, umaVersion, signature, nonce, and timestamp are required") @@ -242,14 +251,21 @@ func ParseLnurlpRequest(url url.URL) (*LnurlpRequest, error) { return nil, UnsupportedVersionError{} } + nilIfEmpty := func(s string) *string { + if s == "" { + return nil + } + return &s + } + return &LnurlpRequest{ - VaspDomain: vaspDomain, - UmaVersion: umaVersion, - Signature: signature, ReceiverAddress: receiverAddress, - Nonce: nonce, + VaspDomain: nilIfEmpty(vaspDomain), + UmaVersion: nilIfEmpty(umaVersion), + Signature: nilIfEmpty(signature), + Nonce: nilIfEmpty(nonce), Timestamp: timestampAsTime, - IsSubjectToTravelRule: strings.ToLower(isSubjectToTravelRule) == "true", + IsSubjectToTravelRule: &isSubjectToTravelRule, }, nil } @@ -260,55 +276,90 @@ func ParseLnurlpRequest(url url.URL) (*LnurlpRequest, error) { // query: the signed query to verify. // otherVaspSigningPubKey: the public key of the VASP making this request in bytes. // nonceCache: the NonceCache cache to use to prevent replay attacks. -func VerifyUmaLnurlpQuerySignature(query *LnurlpRequest, otherVaspSigningPubKey []byte, nonceCache NonceCache) error { +func VerifyUmaLnurlpQuerySignature(query UmaLnurlpRequest, otherVaspSigningPubKey []byte, nonceCache NonceCache) error { err := nonceCache.CheckAndSaveNonce(query.Nonce, query.Timestamp) if err != nil { return err } - return verifySignature(query.signablePayload(), query.Signature, otherVaspSigningPubKey) + signablePayload, err := query.signablePayload() + if err != nil { + return err + } + return verifySignature(signablePayload, query.Signature, otherVaspSigningPubKey) } func GetLnurlpResponse( - request *LnurlpRequest, - privateKeyBytes []byte, - requiresTravelRuleInfo bool, + request LnurlpRequest, callback string, encodedMetadata string, minSendableSats int64, maxSendableSats int64, - payerDataOptions CounterPartyDataOptions, - currencyOptions []Currency, - receiverKycStatus KycStatus, + privateKeyBytes *[]byte, + requiresTravelRuleInfo *bool, + payerDataOptions *CounterPartyDataOptions, + currencyOptions *[]Currency, + receiverKycStatus *KycStatus, + commentCharsAllowed *int, + nostrPubkey *string, ) (*LnurlpResponse, error) { - umaVersion, err := SelectLowerVersion(request.UmaVersion, UmaProtocolVersion) - if err != nil { - return nil, err - } + isUmaRequest := request.IsUmaRequest() + var complianceResponse *LnurlComplianceResponse + var umaVersion *string + + if isUmaRequest { + requiredUmaFields := map[string]interface{}{ + "privateKeyBytes": privateKeyBytes, + "requiresTravelRuleInfo": requiresTravelRuleInfo, + "payerDataOptions": payerDataOptions, + "receiverKycStatus": receiverKycStatus, + "currencyOptions": currencyOptions, + } + for fieldName, fieldValue := range requiredUmaFields { + if fieldValue == nil { + return nil, errors.New("missing required field for UMA: " + fieldName) + } + } + var err error + umaVersion, err = SelectLowerVersion(*request.UmaVersion, UmaProtocolVersion) + if err != nil { + return nil, err + } - complianceResponse, err := getSignedLnurlpComplianceResponse(request, privateKeyBytes, requiresTravelRuleInfo, receiverKycStatus) - if err != nil { - return nil, err - } + complianceResponse, err = getSignedLnurlpComplianceResponse(request, *privateKeyBytes, *requiresTravelRuleInfo, *receiverKycStatus) + if err != nil { + return nil, err + } - // UMA always requires compliance and identifier fields: - payerDataOptions[CounterPartyDataFieldCompliance.String()] = CounterPartyDataOption{Mandatory: true} - payerDataOptions[CounterPartyDataFieldIdentifier.String()] = CounterPartyDataOption{Mandatory: true} + // UMA always requires compliance and identifier fields: + if payerDataOptions == nil { + payerDataOptions = &CounterPartyDataOptions{} + } + (*payerDataOptions)[CounterPartyDataFieldCompliance.String()] = CounterPartyDataOption{Mandatory: true} + (*payerDataOptions)[CounterPartyDataFieldIdentifier.String()] = CounterPartyDataOption{Mandatory: true} + } + var allowsNostr *bool = nil + if nostrPubkey != nil { + *allowsNostr = true + } return &LnurlpResponse{ - Tag: "payRequest", - Callback: callback, - MinSendable: minSendableSats * 1000, - MaxSendable: maxSendableSats * 1000, - EncodedMetadata: encodedMetadata, - Currencies: currencyOptions, - RequiredPayerData: payerDataOptions, - Compliance: *complianceResponse, - UmaVersion: *umaVersion, + Tag: "payRequest", + Callback: callback, + MinSendable: minSendableSats * 1000, + MaxSendable: maxSendableSats * 1000, + EncodedMetadata: encodedMetadata, + Currencies: currencyOptions, + RequiredPayerData: payerDataOptions, + Compliance: complianceResponse, + UmaVersion: umaVersion, + CommentCharsAllowed: commentCharsAllowed, + NostrPubkey: nostrPubkey, + AllowsNostr: allowsNostr, }, nil } func getSignedLnurlpComplianceResponse( - query *LnurlpRequest, + query LnurlpRequest, privateKeyBytes []byte, isSubjectToTravelRule bool, receiverKycStatus KycStatus, @@ -340,7 +391,7 @@ func getSignedLnurlpComplianceResponse( // response: the signed response to verify. // otherVaspSigningPubKey: the public key of the VASP making this request in bytes. // nonceCache: the NonceCache cache to use to prevent replay attacks. -func VerifyUmaLnurlpResponseSignature(response *LnurlpResponse, otherVaspSigningPubKey []byte, nonceCache NonceCache) error { +func VerifyUmaLnurlpResponseSignature(response UmaLnurlpResponse, otherVaspSigningPubKey []byte, nonceCache NonceCache) error { err := nonceCache.CheckAndSaveNonce(response.Compliance.Nonce, time.Unix(response.Compliance.Timestamp, 0)) if err != nil { return err @@ -366,35 +417,38 @@ func GetVaspDomainFromUmaAddress(umaAddress string) (string, error) { return addressParts[1], nil } -// GetPayRequest Creates a signed uma pay request. +// GetUmaPayRequest Creates a signed UMA pay request. For non-UMA LNURL requests, just construct a PayRequest directly. // // Args: // +// amount: the amount of the payment in the smallest unit of the specified currency (i.e. cents for USD). // receiverEncryptionPubKey: the public key of the receiver that will be used to encrypt the travel rule information. // sendingVaspPrivateKey: the private key of the VASP that is sending the payment. This will be used to sign the request. // receivingCurrencyCode: the code of the currency that the receiver will receive for this payment. // isAmountInReceivingCurrency: whether the amount field is specified in the smallest unit of the receiving // currency or in msats (if false). -// amount: the amount of the payment in the smallest unit of the specified currency (i.e. cents for USD). // payerIdentifier: the identifier of the sender. For example, $alice@vasp1.com // payerName: the name of the sender (optional). // payerEmail: the email of the sender (optional). // trInfo: the travel rule information. This will be encrypted before sending to the receiver. // trInfoFormat: the standardized format of the travel rule information (e.g. IVMS). Null indicates raw json or a // custom format, or no travel rule information. -// isPayerKYCd: whether the sender is a KYC'd customer of the sending VASP. +// payerKycStatus: whether the sender is a KYC'd customer of the sending VASP. // payerUtxos: the list of UTXOs of the sender's channels that might be used to fund the payment. // payerNodePubKey: If known, the public key of the sender's node. If supported by the receiving VASP's compliance provider, // this will be used to pre-screen the sender's UTXOs for compliance purposes. // utxoCallback: the URL that the receiver will call to send UTXOs of the channel that the receiver used to receive // the payment once it completes. // requestedPayeeData: the payer data options that the sender is requesting about the receiver. -func GetPayRequest( +// comment: a comment that the sender would like to include with the payment. This can only be included +// if the receiver included the `commentAllowed` field in the lnurlp response. The length of +// the comment must be less than or equal to the value of `commentAllowed`. +func GetUmaPayRequest( + amount int64, receiverEncryptionPubKey []byte, sendingVaspPrivateKey []byte, receivingCurrencyCode string, isAmountInReceivingCurrency bool, - amount int64, payerIdentifier string, payerName *string, payerEmail *string, @@ -405,6 +459,7 @@ func GetPayRequest( payerNodePubKey *string, utxoCallback string, requestedPayeeData *CounterPartyDataOptions, + comment *string, ) (*PayRequest, error) { complianceData, err := getSignedCompliancePayerData( receiverEncryptionPubKey, @@ -432,17 +487,23 @@ func GetPayRequest( sendingAmountCurrencyCode = nil } + complianceDataMap, err := complianceData.AsMap() + if err != nil { + return nil, err + } + return &PayRequest{ SendingAmountCurrencyCode: sendingAmountCurrencyCode, - ReceivingCurrencyCode: receivingCurrencyCode, + ReceivingCurrencyCode: &receivingCurrencyCode, Amount: amount, - PayerData: PayerData{ + PayerData: &PayerData{ CounterPartyDataFieldName.String(): payerName, CounterPartyDataFieldEmail.String(): payerEmail, CounterPartyDataFieldIdentifier.String(): payerIdentifier, - CounterPartyDataFieldCompliance.String(): complianceData, + CounterPartyDataFieldCompliance.String(): complianceDataMap, }, RequestedPayeeData: requestedPayeeData, + Comment: comment, }, nil } @@ -512,8 +573,65 @@ func ParsePayRequest(bytes []byte) (*PayRequest, error) { return &response, nil } -type UmaInvoiceCreator interface { - CreateUmaInvoice(amountMsats int64, metadata string) (*string, error) +// ParsePayRequestFromQueryParams Parses a pay request from query parameters. +// This is useful for parsing a non-UMA pay request from a URL query since raw LNURL uses a GET request for the payreq, +// whereas UMA uses a POST request. +func ParsePayRequestFromQueryParams(query url.Values) (*PayRequest, error) { + amountStr := query.Get("amount") + if amountStr == "" { + return nil, errors.New("missing amount") + } + amountParts := strings.Split(amountStr, ".") + if len(amountParts) > 2 { + return nil, errors.New("invalid amount") + } + amount, err := strconv.ParseInt(amountParts[0], 10, 64) + if err != nil { + return nil, err + } + var sendingAmountCurrencyCode *string + if len(amountParts) == 2 { + sendingAmountCurrencyCode = &amountParts[1] + } + receivingCurrencyCodeStr := query.Get("convert") + var receivingCurrencyCode *string + if receivingCurrencyCodeStr != "" { + receivingCurrencyCode = &receivingCurrencyCodeStr + } + + payerData := query.Get("payerData") + var payerDataObj *PayerData + if payerData != "" { + err = json.Unmarshal([]byte(payerData), &payerDataObj) + if err != nil { + return nil, err + } + } + requestedPayeeData := query.Get("payeeData") + var requestedPayeeDataObj *CounterPartyDataOptions + if requestedPayeeData != "" { + err = json.Unmarshal([]byte(requestedPayeeData), &requestedPayeeDataObj) + if err != nil { + return nil, err + } + } + commentParam := query.Get("comment") + var comment *string + if commentParam != "" { + comment = &commentParam + } + return &PayRequest{ + SendingAmountCurrencyCode: sendingAmountCurrencyCode, + ReceivingCurrencyCode: receivingCurrencyCode, + Amount: amount, + PayerData: payerDataObj, + RequestedPayeeData: requestedPayeeDataObj, + Comment: comment, + }, nil +} + +type InvoiceCreator interface { + CreateInvoice(amountMsats int64, metadata string) (*string, error) } // GetPayReqResponse Creates an uma pay request response with an encoded invoice. @@ -541,86 +659,190 @@ type UmaInvoiceCreator interface { // mandatory. The data provided does not need to include compliance data, as it will be added automatically. // receivingVaspPrivateKey: the private key of the VASP that is receiving the payment. This will be used to sign the request. // payeeIdentifier: the identifier of the receiver. For example, $bob@vasp2.com +// disposable: This field may be used by a WALLET to decide whether the initial LNURL link will be stored locally +// for later reuse or erased. If disposable is null, it should be interpreted as true, so if SERVICE intends +// its LNURL links to be stored it must return `disposable: false`. UMA should never return +// `disposable: false`. See LUD-11. +// successAction: an optional action that the wallet should take once the payment is complete. See LUD-09. func GetPayReqResponse( - query *PayRequest, - invoiceCreator UmaInvoiceCreator, + request PayRequest, + invoiceCreator InvoiceCreator, metadata string, - receivingCurrencyCode string, - receivingCurrencyDecimals int, - conversionRate float64, - receiverFeesMillisats int64, - receiverChannelUtxos []string, + receivingCurrencyCode *string, + receivingCurrencyDecimals *int, + conversionRate *float64, + receiverFeesMillisats *int64, + receiverChannelUtxos *[]string, receiverNodePubKey *string, - utxoCallback string, + utxoCallback *string, payeeData *PayeeData, - receivingVaspPrivateKey []byte, - payeeIdentifier string, + receivingVaspPrivateKey *[]byte, + payeeIdentifier *string, + disposable *bool, + successAction *map[string]string, ) (*PayReqResponse, error) { - if query.SendingAmountCurrencyCode != nil && *query.SendingAmountCurrencyCode != receivingCurrencyCode { - return nil, errors.New("sending and receiving currency code mismatch") + if request.SendingAmountCurrencyCode != nil && *request.SendingAmountCurrencyCode != *receivingCurrencyCode { + return nil, errors.New("the sdk only supports sending in either SAT or the receiving currency") } - msatsAmount := int64(math.Round(float64(query.Amount)*conversionRate)) + receiverFeesMillisats - receivingCurrencyAmount := query.Amount - if query.SendingAmountCurrencyCode == nil { - msatsAmount = query.Amount - receivingCurrencyAmount = int64(math.Round(float64(msatsAmount-receiverFeesMillisats) / conversionRate)) - } - encodedPayerData, err := json.Marshal(query.PayerData) + err := validatePayReqCurrencyFields(receivingCurrencyCode, receivingCurrencyDecimals, conversionRate, receiverFeesMillisats) if err != nil { return nil, err } - encodedInvoice, err := invoiceCreator.CreateUmaInvoice(msatsAmount, metadata+"{"+string(encodedPayerData)+"}") - if err != nil { - return nil, err + conversionRateOrOne := 1.0 + if conversionRate != nil { + conversionRateOrOne = *conversionRate } - payerIdentifier := query.PayerData.Identifier() - if payerIdentifier == nil || *payerIdentifier == "" { - return nil, errors.New("payer data is missing") + feesOrZero := int64(0) + if receiverFeesMillisats != nil { + feesOrZero = *receiverFeesMillisats } - complianceData, err := getSignedCompliancePayeeData( - receivingVaspPrivateKey, - *payerIdentifier, - payeeIdentifier, - receiverChannelUtxos, - receiverNodePubKey, - utxoCallback, - ) + msatsAmount := request.Amount + if receivingCurrencyCode != nil && request.SendingAmountCurrencyCode != nil { + msatsAmount = int64(math.Round(float64(request.Amount)*(conversionRateOrOne))) + feesOrZero + } + receivingCurrencyAmount := request.Amount + if request.SendingAmountCurrencyCode == nil { + receivingCurrencyAmount = int64(math.Round(float64(msatsAmount-feesOrZero) / conversionRateOrOne)) + } + + payerDataStr := "" + if request.PayerData != nil { + encodedPayerData, err := json.Marshal(*(request.PayerData)) + if err != nil { + return nil, err + } + payerDataStr = string(encodedPayerData) + } + encodedInvoice, err := invoiceCreator.CreateInvoice(msatsAmount, metadata+payerDataStr) if err != nil { return nil, err } - if payeeData == nil { - payeeData = &PayeeData{ - CounterPartyDataFieldIdentifier.String(): payerIdentifier, + var complianceData *CompliancePayeeData + if request.IsUmaRequest() { + err = validateUmaPayReqFields( + receivingCurrencyCode, + receivingCurrencyDecimals, + conversionRate, + receiverFeesMillisats, + receiverChannelUtxos, + receiverNodePubKey, + payeeIdentifier, + receivingVaspPrivateKey, + ) + if err != nil { + return nil, err } - } - if existingCompliance := (*payeeData)[CounterPartyDataFieldCompliance.String()]; existingCompliance == nil { - complianceDataAsMap, err := complianceData.AsMap() + + payerIdentifier := request.PayerData.Identifier() + var utxos []string + if receiverChannelUtxos != nil { + utxos = *receiverChannelUtxos + } + complianceData, err = getSignedCompliancePayeeData( + *receivingVaspPrivateKey, + *payerIdentifier, + *payeeIdentifier, + utxos, + receiverNodePubKey, + utxoCallback, + ) if err != nil { return nil, err } - (*payeeData)[CounterPartyDataFieldCompliance.String()] = complianceDataAsMap + if payeeData == nil { + payeeData = &PayeeData{ + CounterPartyDataFieldIdentifier.String(): *payeeIdentifier, + } + } + if existingCompliance := (*payeeData)[CounterPartyDataFieldCompliance.String()]; existingCompliance == nil { + complianceDataAsMap, err := complianceData.AsMap() + if err != nil { + return nil, err + } + (*payeeData)[CounterPartyDataFieldCompliance.String()] = complianceDataAsMap + } + } + + var paymentInfo *PayReqResponsePaymentInfo + if receivingCurrencyCode != nil { + paymentInfo = &PayReqResponsePaymentInfo{ + Amount: receivingCurrencyAmount, + CurrencyCode: *receivingCurrencyCode, + Multiplier: *conversionRate, + Decimals: *receivingCurrencyDecimals, + ExchangeFeesMillisatoshi: *receiverFeesMillisats, + } } return &PayReqResponse{ EncodedInvoice: *encodedInvoice, Routes: []Route{}, - PaymentInfo: PayReqResponsePaymentInfo{ - Amount: receivingCurrencyAmount, - CurrencyCode: receivingCurrencyCode, - Multiplier: conversionRate, - Decimals: receivingCurrencyDecimals, - ExchangeFeesMillisatoshi: receiverFeesMillisats, - }, - PayeeData: payeeData, + PaymentInfo: paymentInfo, + PayeeData: payeeData, + Disposable: disposable, + SuccessAction: successAction, }, nil } +func validatePayReqCurrencyFields( + receivingCurrencyCode *string, + receivingCurrencyDecimals *int, + conversionRate *float64, + receiverFeesMillisats *int64, +) error { + numNilFields := 0 + if receivingCurrencyCode == nil { + numNilFields++ + } + if receivingCurrencyDecimals == nil { + numNilFields++ + } + if conversionRate == nil { + numNilFields++ + } + if receiverFeesMillisats == nil { + numNilFields++ + } + if numNilFields != 0 && numNilFields != 4 { + return errors.New("invalid currency fields. must be all nil or all non-nil") + } + return nil +} + +func validateUmaPayReqFields( + receivingCurrencyCode *string, + receivingCurrencyDecimals *int, + conversionRate *float64, + receiverFeesMillisats *int64, + receiverChannelUtxos *[]string, + receiverNodePubKey *string, + payeeIdentifier *string, + signingPrivateKeyBytes *[]byte, +) error { + if receivingCurrencyCode == nil || receivingCurrencyDecimals == nil || conversionRate == nil || receiverFeesMillisats == nil { + return errors.New("missing currency fields required for UMA") + } + + if payeeIdentifier == nil { + return errors.New("missing required UMA field payeeIdentifier") + } + + if signingPrivateKeyBytes == nil { + return errors.New("missing required UMA field signingPrivateKeyBytes") + } + + if receiverChannelUtxos == nil && receiverNodePubKey == nil { + return errors.New("missing required UMA fields. receiverChannelUtxos and/or receiverNodePubKey is required") + } + return nil +} + func getSignedCompliancePayeeData( receivingVaspPrivateKeyBytes []byte, payerIdentifier string, payeeIdentifier string, receiverChannelUtxos []string, receiverNodePubKey *string, - utxoCallback string, + utxoCallback *string, ) (*CompliancePayeeData, error) { timestamp := time.Now().Unix() nonce, err := GenerateNonce() diff --git a/uma/version.go b/uma/version.go index 115e384..993d699 100644 --- a/uma/version.go +++ b/uma/version.go @@ -7,8 +7,8 @@ import ( "strings" ) -const MAJOR_VERSION = 0 -const MINOR_VERSION = 3 +const MAJOR_VERSION = 1 +const MINOR_VERSION = 0 var UmaProtocolVersion = fmt.Sprintf("%d.%d", MAJOR_VERSION, MINOR_VERSION)