diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index e0f1eee..a5b9959 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -4,7 +4,7 @@ on: push: branches: [ "main" ] pull_request: - branches: [ "main" ] + branches: [ "main", "release/v1.0" ] jobs: diff --git a/uma/currency.go b/uma/currency.go deleted file mode 100644 index 22723d1..0000000 --- a/uma/currency.go +++ /dev/null @@ -1,33 +0,0 @@ -package uma - -type Currency struct { - // Code is the ISO 4217 (if applicable) currency code (eg. "USD"). For cryptocurrencies, this will be a ticker - // symbol, such as BTC for Bitcoin. - Code string `json:"code"` - - // Name is the full display name of the currency (eg. US Dollars). - Name string `json:"name"` - - // Symbol is the symbol of the currency (eg. $ for USD). - Symbol string `json:"symbol"` - - // MillisatoshiPerUnit is the estimated millisats per smallest "unit" of this currency (eg. 1 cent in USD). - MillisatoshiPerUnit float64 `json:"multiplier"` - - // MinSendable is the minimum amount of the currency that can be sent in a single transaction. This is in the - // smallest unit of the currency (eg. cents for USD). - MinSendable int64 `json:"minSendable"` - - // MaxSendable is the maximum amount of the currency that can be sent in a single transaction. This is in the - // smallest unit of the currency (eg. cents for USD). - MaxSendable int64 `json:"maxSendable"` - - // Decimals is the number of digits after the decimal point for display on the sender side, and to add clarity - // around what the "smallest unit" of the currency is. For example, in USD, by convention, there are 2 digits for - // cents - $5.95. In this case, `decimals` would be 2. Note that the multiplier is still always in the smallest - // unit (cents). In addition to display purposes, this field can be used to resolve ambiguity in what the multiplier - // means. For example, if the currency is "BTC" and the multiplier is 1000, really we're exchanging in SATs, so - // `decimals` would be 8. - // For details on edge cases and examples, see https://github.com/uma-universal-money-address/protocol/blob/main/umad-04-lnurlp-response.md. - Decimals int `json:"decimals"` -} diff --git a/uma/protocol.go b/uma/protocol.go deleted file mode 100644 index 2615a06..0000000 --- a/uma/protocol.go +++ /dev/null @@ -1,202 +0,0 @@ -package uma - -import ( - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "net/url" - "strconv" - "strings" - "time" -) - -// LnurlpRequest 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 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 - // 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) EncodeToUrl() (*url.URL, error) { - receiverAddressParts := strings.Split(q.ReceiverAddress, "@") - if len(receiverAddressParts) != 2 { - return nil, errors.New("invalid receiver address") - } - scheme := "https" - if IsDomainLocalhost(receiverAddressParts[1]) { - scheme = "http" - } - lnurlpUrl := url.URL{ - Scheme: scheme, - Host: receiverAddressParts[1], - 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) - 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) -} - -// 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 PayerDataOptions `json:"payerData"` - 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"` -} - -// LnurlComplianceResponse is the `compliance` field of the LnurlpResponse. -type LnurlComplianceResponse struct { - // KycStatus indicates whether VASP2 has KYC information about the receiver. - KycStatus KycStatus `json:"kycStatus"` - // Signature is the base64-encoded signature of sha256(ReceiverAddress|Nonce|Timestamp). - Signature string `json:"signature"` - // Nonce is a random string that is used to prevent replay attacks. - Nonce string `json:"signatureNonce"` - // Timestamp is the unix timestamp of when the request was sent. Used in the signature. - Timestamp int64 `json:"signatureTimestamp"` - // IsSubjectToTravelRule indicates whether VASP2 is a financial institution that requires travel rule information. - IsSubjectToTravelRule bool `json:"isSubjectToTravelRule"` - // ReceiverIdentifier is the identifier of the receiver at VASP2. - ReceiverIdentifier string `json:"receiverIdentifier"` -} - -func (r *LnurlpResponse) signablePayload() []byte { - payloadString := strings.Join([]string{ - r.Compliance.ReceiverIdentifier, - r.Compliance.Nonce, - strconv.FormatInt(r.Compliance.Timestamp, 10), - }, "|") - return []byte(payloadString) -} - -// PayRequest is the request sent by the sender to the receiver to retrieve an invoice. -type PayRequest struct { - // CurrencyCode is the ISO 3-digit currency code that the receiver will receive for this payment. - CurrencyCode string `json:"currency"` - // 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). - Amount int64 `json:"amount"` - // PayerData is the data that the sender will send to the receiver to identify themselves. - PayerData PayerData `json:"payerData"` -} - -func (q *PayRequest) Encode() ([]byte, error) { - return json.Marshal(q) -} - -func (q *PayRequest) signablePayload() []byte { - senderAddress := q.PayerData.Identifier - signatureNonce := q.PayerData.Compliance.SignatureNonce - signatureTimestamp := q.PayerData.Compliance.SignatureTimestamp - payloadString := strings.Join([]string{senderAddress, signatureNonce, strconv.FormatInt(signatureTimestamp, 10)}, "|") - return []byte(payloadString) -} - -// PayReqResponse is the response sent by the receiver to the sender to provide an invoice. -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"` - Compliance PayReqResponseCompliance `json:"compliance"` - PaymentInfo PayReqResponsePaymentInfo `json:"paymentInfo"` -} - -type Route struct { - Pubkey string `json:"pubkey"` - Path []struct { - Pubkey string `json:"pubkey"` - Fee int64 `json:"fee"` - Msatoshi int64 `json:"msatoshi"` - Channel string `json:"channel"` - } `json:"path"` -} - -type PayReqResponseCompliance struct { - // NodePubKey is the public key of the receiver's node if known. - NodePubKey *string `json:"nodePubKey"` - // 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"` -} - -type PayReqResponsePaymentInfo struct { - // CurrencyCode is the currency code that the receiver will receive for this payment. - CurrencyCode string `json:"currencyCode"` - // Multiplier is the conversion rate. It is the number of millisatoshis that the receiver will receive for 1 unit of the specified currency. - // In this context, this is just for convenience. The conversion rate is also baked into the invoice amount itself. - // `invoice amount = amount * multiplier + exchangeFeesMillisatoshi` - Multiplier float64 `json:"multiplier"` - // Decimals is the number of digits after the decimal point for the receiving currency. For example, in USD, by - // convention, there are 2 digits for cents - $5.95. In this case, `Decimals` would be 2. This should align with the - // currency's `Decimals` field in the LNURLP response. It is included here for convenience. See - // [UMAD-04](/uma-04-local-currency.md) for details, edge cases, and examples. - Decimals int `json:"decimals"` - // ExchangeFeesMillisatoshi is the fees charged (in millisats) by the receiving VASP for this transaction. This is - // separate from the Multiplier. - ExchangeFeesMillisatoshi int64 `json:"exchangeFeesMillisatoshi"` -} - -// PubKeyResponse is sent from a VASP to another VASP to provide its public keys. -// It is the response to GET requests at `/.well-known/lnurlpubkey`. -type PubKeyResponse struct { - // SigningPubKeyHex is used to verify signatures from a VASP. Hex-encoded byte array. - SigningPubKeyHex string `json:"signingPubKey"` - // EncryptionPubKeyHex is used to encrypt TR info sent to a VASP. Hex-encoded byte array. - EncryptionPubKeyHex string `json:"encryptionPubKey"` - // ExpirationTimestamp [Optional] Seconds since epoch at which these pub keys must be refreshed. - // They can be safely cached until this expiration (or forever if null). - ExpirationTimestamp *int64 `json:"expirationTimestamp"` -} - -func (r *PubKeyResponse) SigningPubKey() ([]byte, error) { - return hex.DecodeString(r.SigningPubKeyHex) -} - -func (r *PubKeyResponse) EncryptionPubKey() ([]byte, error) { - return hex.DecodeString(r.EncryptionPubKeyHex) -} - -// UtxoWithAmount is a pair of utxo and amount transferred over that corresponding channel. -// It can be used to register payment for KYT. -type UtxoWithAmount struct { - // Utxo The utxo of the channel over which the payment went through in the format of :. - Utxo string `json:"utxo"` - - // Amount The amount of funds transferred in the payment in mSats. - Amount int64 `json:"amountMsats"` -} diff --git a/uma/protocol/counter_party_data.go b/uma/protocol/counter_party_data.go new file mode 100644 index 0000000..6ec0cfb --- /dev/null +++ b/uma/protocol/counter_party_data.go @@ -0,0 +1,24 @@ +package protocol + +type CounterPartyDataOption struct { + Mandatory bool `json:"mandatory"` +} + +// CounterPartyDataOptions describes which fields a vasp needs to know about the sender or receiver. Used for payerData +// and payeeData. +type CounterPartyDataOptions map[string]CounterPartyDataOption + +type CounterPartyDataField string + +const ( + CounterPartyDataFieldIdentifier CounterPartyDataField = "identifier" + CounterPartyDataFieldName CounterPartyDataField = "name" + CounterPartyDataFieldEmail CounterPartyDataField = "email" + CounterPartyDataFieldCountryCode CounterPartyDataField = "countryCode" + CounterPartyDataFieldCompliance CounterPartyDataField = "compliance" + CounterPartyDataFieldAccountNumber CounterPartyDataField = "accountNumber" +) + +func (c CounterPartyDataField) String() string { + return string(c) +} diff --git a/uma/protocol/currency.go b/uma/protocol/currency.go new file mode 100644 index 0000000..c9bab40 --- /dev/null +++ b/uma/protocol/currency.go @@ -0,0 +1,119 @@ +package protocol + +import "encoding/json" + +type Currency struct { + // Code is the ISO 4217 (if applicable) currency code (eg. "USD"). For cryptocurrencies, this will be a ticker + // symbol, such as BTC for Bitcoin. + Code string `json:"code"` + + // Name is the full display name of the currency (eg. US Dollars). + Name string `json:"name"` + + // Symbol is the symbol of the currency (eg. $ for USD). + Symbol string `json:"symbol"` + + // MillisatoshiPerUnit is the estimated millisats per smallest "unit" of this currency (eg. 1 cent in USD). + MillisatoshiPerUnit float64 `json:"multiplier"` + + // Convertible is a struct which contains the range of amounts that can be sent in a single transaction. + Convertible ConvertibleCurrency `json:"convertible"` + + // Decimals is the number of digits after the decimal point for display on the sender side, and to add clarity + // around what the "smallest unit" of the currency is. For example, in USD, by convention, there are 2 digits for + // cents - $5.95. In this case, `decimals` would be 2. Note that the multiplier is still always in the smallest + // unit (cents). In addition to display purposes, this field can be used to resolve ambiguity in what the multiplier + // means. For example, if the currency is "BTC" and the multiplier is 1000, really we're exchanging in SATs, so + // `decimals` would be 8. + // For details on edge cases and examples, see https://github.com/uma-universal-money-address/protocol/blob/main/umad-04-lnurlp-response.md. + Decimals int `json:"decimals"` + + // UmaMajorVersion is the major version of the UMA protocol that the VASP supports for this currency. This is used + // for serialization, but is not serialized itself. + UmaMajorVersion int `json:"-"` +} + +type ConvertibleCurrency struct { + // MinSendable is the minimum amount of the currency that can be sent in a single transaction. This is in the + // smallest unit of the currency (eg. cents for USD). + MinSendable int64 `json:"min"` + + // MaxSendable is the maximum amount of the currency that can be sent in a single transaction. This is in the + // smallest unit of the currency (eg. cents for USD). + MaxSendable int64 `json:"max"` +} + +type v0Currency struct { + Code string `json:"code"` + Name string `json:"name"` + Symbol string `json:"symbol"` + MillisatoshiPerUnit float64 `json:"multiplier"` + MinSendable int64 `json:"minSendable"` + MaxSendable int64 `json:"maxSendable"` + Decimals int `json:"decimals"` +} + +type v1Currency struct { + Code string `json:"code"` + Name string `json:"name"` + Symbol string `json:"symbol"` + MillisatoshiPerUnit float64 `json:"multiplier"` + Convertible ConvertibleCurrency `json:"convertible"` + Decimals int `json:"decimals"` +} + +func (c *Currency) MarshalJSON() ([]byte, error) { + if c.UmaMajorVersion == 0 { + return json.Marshal(&v0Currency{ + Code: c.Code, + Name: c.Name, + Symbol: c.Symbol, + MillisatoshiPerUnit: c.MillisatoshiPerUnit, + MinSendable: c.Convertible.MinSendable, + MaxSendable: c.Convertible.MaxSendable, + Decimals: c.Decimals, + }) + } + return json.Marshal(&v1Currency{ + Code: c.Code, + Name: c.Name, + Symbol: c.Symbol, + MillisatoshiPerUnit: c.MillisatoshiPerUnit, + Convertible: c.Convertible, + Decimals: c.Decimals, + }) +} + +func (c *Currency) UnmarshalJSON(data []byte) error { + jsonData := make(map[string]interface{}) + if err := json.Unmarshal(data, &jsonData); err != nil { + return err + } + if _, ok := jsonData["minSendable"]; ok { + v0 := &v0Currency{} + if err := json.Unmarshal(data, v0); err != nil { + return err + } + c.Code = v0.Code + c.Name = v0.Name + c.Symbol = v0.Symbol + c.MillisatoshiPerUnit = v0.MillisatoshiPerUnit + c.Convertible.MinSendable = v0.MinSendable + c.Convertible.MaxSendable = v0.MaxSendable + c.Decimals = v0.Decimals + c.UmaMajorVersion = 0 + return nil + } + v1 := &v1Currency{} + if err := json.Unmarshal(data, v1); err != nil { + return err + } + c.Code = v1.Code + c.Name = v1.Name + c.Symbol = v1.Symbol + c.MillisatoshiPerUnit = v1.MillisatoshiPerUnit + c.Convertible = v1.Convertible + c.Decimals = v1.Decimals + c.UmaMajorVersion = 1 + return nil +} diff --git a/uma/kyc_status.go b/uma/protocol/kyc_status.go similarity index 98% rename from uma/kyc_status.go rename to uma/protocol/kyc_status.go index 0df3702..5dfc941 100644 --- a/uma/kyc_status.go +++ b/uma/protocol/kyc_status.go @@ -1,4 +1,4 @@ -package uma +package protocol import "encoding/json" diff --git a/uma/protocol/lnurl_request.go b/uma/protocol/lnurl_request.go new file mode 100644 index 0000000..9758b87 --- /dev/null +++ b/uma/protocol/lnurl_request.go @@ -0,0 +1,111 @@ +package protocol + +import ( + "errors" + "fmt" + "github.com/uma-universal-money-address/uma-go-sdk/uma/utils" + "net/url" + "strconv" + "strings" + "time" +) + +// LnurlpRequest 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 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 + // 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 +} + +// 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) { + receiverAddressParts := strings.Split(q.ReceiverAddress, "@") + if len(receiverAddressParts) != 2 { + return nil, errors.New("invalid receiver address") + } + scheme := "https" + if utils.IsDomainLocalhost(receiverAddressParts[1]) { + scheme = "http" + } + lnurlpUrl := url.URL{ + Scheme: scheme, + Host: receiverAddressParts[1], + Path: fmt.Sprintf("/.well-known/lnurlp/%s", receiverAddressParts[0]), + } + queryParams := lnurlpUrl.Query() + 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 +} + +// 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 +} diff --git a/uma/protocol/lnurl_response.go b/uma/protocol/lnurl_response.go new file mode 100644 index 0000000..a7a3626 --- /dev/null +++ b/uma/protocol/lnurl_response.go @@ -0,0 +1,101 @@ +package protocol + +import ( + "strconv" + "strings" +) + +// 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 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"` +} + +// LnurlComplianceResponse is the `compliance` field of the LnurlpResponse. +type LnurlComplianceResponse struct { + // KycStatus indicates whether VASP2 has KYC information about the receiver. + KycStatus KycStatus `json:"kycStatus"` + // Signature is the base64-encoded signature of sha256(ReceiverAddress|Nonce|Timestamp). + Signature string `json:"signature"` + // Nonce is a random string that is used to prevent replay attacks. + Nonce string `json:"signatureNonce"` + // Timestamp is the unix timestamp of when the request was sent. Used in the signature. + Timestamp int64 `json:"signatureTimestamp"` + // IsSubjectToTravelRule indicates whether VASP2 is a financial institution that requires travel rule information. + IsSubjectToTravelRule bool `json:"isSubjectToTravelRule"` + // ReceiverIdentifier is the identifier of the receiver at VASP2. + ReceiverIdentifier string `json:"receiverIdentifier"` +} + +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, + strconv.FormatInt(r.Compliance.Timestamp, 10), + }, "|") + return []byte(payloadString) +} diff --git a/uma/protocol/pay_request.go b/uma/protocol/pay_request.go new file mode 100644 index 0000000..db9ccbf --- /dev/null +++ b/uma/protocol/pay_request.go @@ -0,0 +1,304 @@ +package protocol + +import ( + "encoding/json" + "errors" + "fmt" + "net/url" + "strconv" + "strings" +) + +// PayRequest is the request sent by the sender to the receiver to retrieve an invoice. +type PayRequest struct { + // SendingAmountCurrencyCode is the currency code of the `amount` field. `nil` indicates that `amount` is in + // millisatoshis as in LNURL without LUD-21. If this is not `nil`, then `amount` is in the smallest unit of the + // specified currency (e.g. cents for USD). This currency code can be any currency which the receiver can quote. + // However, there are two most common scenarios for UMA: + // + // 1. If the sender wants the receiver wants to receive a specific amount in their receiving + // currency, then this field should be the same as `receiving_currency_code`. This is useful + // for cases where the sender wants to ensure that the receiver receives a specific amount + // in that destination currency, regardless of the exchange rate, for example, when paying + // for some goods or services in a foreign currency. + // + // 2. If the sender has a specific amount in their own currency that they would like to send, + // then this field should be left as `None` to indicate that the amount is in millisatoshis. + // This will lock the sent amount on the sender side, and the receiver will receive the + // equivalent amount in their receiving currency. NOTE: In this scenario, the sending VASP + // *should not* pass the sending currency code here, as it is not relevant to the receiver. + // 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. 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. 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"` + // UmaMajorVersion is the major version of the UMA protocol that the VASP supports for this currency. This is used + // for serialization, but is not serialized itself. + UmaMajorVersion int `json:"-"` +} + +type v0PayRequest struct { + ReceivingCurrencyCode *string `json:"currency"` + Amount int64 `json:"amount"` + PayerData *PayerData `json:"payerData"` + RequestedPayeeData *CounterPartyDataOptions `json:"payeeData"` + Comment *string `json:"comment"` +} + +type v1PayRequest struct { + ReceivingCurrencyCode *string `json:"convert"` + Amount string `json:"amount"` + PayerData *PayerData `json:"payerData"` + RequestedPayeeData *CounterPartyDataOptions `json:"payeeData"` + 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) { + if p.UmaMajorVersion == 0 { + return json.Marshal(&v0PayRequest{ + ReceivingCurrencyCode: p.ReceivingCurrencyCode, + Amount: p.Amount, + PayerData: p.PayerData, + RequestedPayeeData: p.RequestedPayeeData, + Comment: p.Comment, + }) + } + + amount := strconv.FormatInt(p.Amount, 10) + if p.SendingAmountCurrencyCode != nil { + amount = fmt.Sprintf("%s.%s", amount, *p.SendingAmountCurrencyCode) + } + return json.Marshal(&v1PayRequest{ + ReceivingCurrencyCode: p.ReceivingCurrencyCode, + Amount: amount, + PayerData: p.PayerData, + RequestedPayeeData: p.RequestedPayeeData, + Comment: p.Comment, + }) +} + +func (p *PayRequest) UnmarshalJSON(data []byte) error { + var rawReq map[string]interface{} + err := json.Unmarshal(data, &rawReq) + if err != nil { + return err + } + isAmountString := false + if _, ok := rawReq["amount"].(string); ok { + isAmountString = true + } else { + _, ok = rawReq["amount"].(float64) + if !ok { + return errors.New("missing or invalid amount field") + } + } + isUma := false + payerData, ok := rawReq["payerData"].(map[string]interface{}) + if ok { + _, ok = payerData["compliance"].(map[string]interface{}) + if ok { + isUma = true + } + } + isV1 := false + if _, ok := rawReq["convert"].(string); ok { + isV1 = isUma + } + if isV1 || isAmountString { + var v1Req v1PayRequest + err = json.Unmarshal(data, &v1Req) + if err != nil { + return err + } + return p.UnmarshalFromV1(v1Req) + } + var v0Req v0PayRequest + err = json.Unmarshal(data, &v0Req) + if err != nil { + return err + } + err = p.UnmarshalFromV0(v0Req) + if err != nil { + return err + } + return nil +} + +func (p *PayRequest) UnmarshalFromV1(request v1PayRequest) error { + p.UmaMajorVersion = 1 + p.ReceivingCurrencyCode = request.ReceivingCurrencyCode + p.PayerData = request.PayerData + p.RequestedPayeeData = request.RequestedPayeeData + p.Comment = request.Comment + amount := request.Amount + amountParts := strings.Split(amount, ".") + if len(amountParts) > 2 { + return errors.New("invalid amount field") + } + var err error + p.Amount, err = strconv.ParseInt(amountParts[0], 10, 64) + if err != nil { + return err + } + if len(amountParts) == 2 && len(amountParts[1]) > 0 { + p.SendingAmountCurrencyCode = &amountParts[1] + } + return nil +} + +func (p *PayRequest) UnmarshalFromV0(request v0PayRequest) error { + p.UmaMajorVersion = 0 + p.ReceivingCurrencyCode = request.ReceivingCurrencyCode + p.PayerData = request.PayerData + p.RequestedPayeeData = request.RequestedPayeeData + p.Comment = request.Comment + p.Amount = request.Amount + return nil +} + +func (p *PayRequest) Encode() ([]byte, error) { + return p.MarshalJSON() +} + +func (p *PayRequest) EncodeAsUrlParams() (*url.Values, error) { + jsonBytes, err := p.MarshalJSON() + 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") + } + 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 payer data is missing") + } + signatureNonce := complianceData.SignatureNonce + signatureTimestamp := complianceData.SignatureTimestamp + payloadString := strings.Join([]string{ + *senderAddress, + signatureNonce, + strconv.FormatInt(signatureTimestamp, 10), + }, "|") + return []byte(payloadString), nil +} + +// 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] + } + v1ReceivingCurrencyCodeStr := query.Get("convert") + v0ReceivingCurrencyCodeStr := query.Get("currency") + umaMajorVersion := 1 + var receivingCurrencyCode *string + if v1ReceivingCurrencyCodeStr != "" { + receivingCurrencyCode = &v1ReceivingCurrencyCodeStr + } else if v0ReceivingCurrencyCodeStr != "" { + receivingCurrencyCode = &v0ReceivingCurrencyCodeStr + umaMajorVersion = 0 + } + + 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, + UmaMajorVersion: umaMajorVersion, + }, nil +} diff --git a/uma/protocol/payee_data.go b/uma/protocol/payee_data.go new file mode 100644 index 0000000..33104b8 --- /dev/null +++ b/uma/protocol/payee_data.go @@ -0,0 +1,79 @@ +package protocol + +import ( + "encoding/json" + "errors" + "strconv" + "strings" +) + +// PayeeData is the data that the payer wants to know about the payee. It can be any json data. +type PayeeData map[string]interface{} + +func (p *PayeeData) Compliance() (*CompliancePayeeData, error) { + if p == nil { + return nil, nil + } + if compliance, ok := (*p)["compliance"]; ok { + if complianceMap, ok := compliance.(map[string]interface{}); ok { + complianceJson, err := json.Marshal(complianceMap) + if err != nil { + return nil, err + } + var complianceData CompliancePayeeData + err = json.Unmarshal(complianceJson, &complianceData) + if err != nil { + return nil, err + } + return &complianceData, nil + } + } + return nil, nil +} + +type CompliancePayeeData struct { + // NodePubKey is the public key of the receiver's node if known. + NodePubKey *string `json:"nodePubKey"` + // 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"` + // Signature is the base64-encoded signature of sha256(SenderAddress|ReceiverAddress|Nonce|Timestamp). + // Note: This field is optional for UMA v0.X backwards-compatibility. It is required for UMA v1.X. + Signature *string `json:"signature"` + // SignatureNonce is a random string that is used to prevent replay attacks. + // Note: This field is optional for UMA v0.X backwards-compatibility. It is required for UMA v1.X. + SignatureNonce *string `json:"signatureNonce"` + // SignatureTimestamp is the unix timestamp (in seconds since epoch) of when the request was sent. Used in the signature. + // Note: This field is optional for UMA v0.X backwards-compatibility. It is required for UMA v1.X. + SignatureTimestamp *int64 `json:"signatureTimestamp"` +} + +func (c *CompliancePayeeData) 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 +} + +func (c *CompliancePayeeData) SignablePayload(payerIdentifier string, payeeIdentifier string) ([]byte, error) { + if c == nil { + return nil, errors.New("compliance data is missing") + } + if c.SignatureNonce == nil || c.SignatureTimestamp == nil { + return nil, errors.New("compliance data is missing signature nonce or timestamp. Is this a v0.X response") + } + payloadString := strings.Join([]string{ + payerIdentifier, + payeeIdentifier, + *c.SignatureNonce, + strconv.FormatInt(*c.SignatureTimestamp, 10), + }, "|") + return []byte(payloadString), nil +} diff --git a/uma/payer_data.go b/uma/protocol/payer_data.go similarity index 62% rename from uma/payer_data.go rename to uma/protocol/payer_data.go index cbe2b8c..5129ddc 100644 --- a/uma/payer_data.go +++ b/uma/protocol/payer_data.go @@ -1,4 +1,4 @@ -package uma +package protocol import ( "encoding/json" @@ -6,52 +6,51 @@ import ( "strings" ) -type PayerDataOptions struct { - NameRequired bool - EmailRequired bool - ComplianceRequired bool -} - -func (p *PayerDataOptions) MarshalJSON() ([]byte, error) { - return []byte(fmt.Sprintf(`{ - "identifier": { "mandatory": true }, - "name": { "mandatory": %t }, - "email": { "mandatory": %t }, - "compliance": { "mandatory": %t } - }`, p.NameRequired, p.EmailRequired, p.ComplianceRequired)), nil -} +type PayerData map[string]interface{} -func decodePayerDataOptionField(data map[string]interface{}, fieldName string) bool { - m, ok := data[fieldName].(map[string]interface{}) - if !ok { - return false +func (p *PayerData) Compliance() (*CompliancePayerData, error) { + if p == nil { + return nil, nil } - mandatory, ok := m["mandatory"].(bool) - if !ok { - return false + if compliance, ok := (*p)["compliance"]; ok { + if complianceMap, ok := compliance.(map[string]interface{}); ok { + complianceJson, err := json.Marshal(complianceMap) + if err != nil { + return nil, err + } + var complianceData CompliancePayerData + err = json.Unmarshal(complianceJson, &complianceData) + if err != nil { + return nil, err + } + return &complianceData, nil + } } - return mandatory + return nil, nil } -func (p *PayerDataOptions) UnmarshalJSON(data []byte) error { - var m map[string]interface{} - err := json.Unmarshal(data, &m) - if err != nil { - return err +func (p *PayerData) stringField(field string) *string { + if p == nil { + return nil + } + if value, ok := (*p)[field]; ok { + if stringValue, ok := value.(string); ok { + return &stringValue + } } + return nil +} - p.NameRequired = decodePayerDataOptionField(m, "name") - p.EmailRequired = decodePayerDataOptionField(m, "email") - p.ComplianceRequired = decodePayerDataOptionField(m, "compliance") +func (p *PayerData) Identifier() *string { + return p.stringField("identifier") +} - return nil +func (p *PayerData) Name() *string { + return p.stringField("name") } -type PayerData struct { - Name *string `json:"name"` - Email *string `json:"email"` - Identifier string `json:"identifier"` - Compliance *CompliancePayerData `json:"compliance"` +func (p *PayerData) Email() *string { + return p.stringField("email") } type TravelRuleFormat struct { @@ -109,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/payreq_response.go b/uma/protocol/payreq_response.go new file mode 100644 index 0000000..9aa5e89 --- /dev/null +++ b/uma/protocol/payreq_response.go @@ -0,0 +1,211 @@ +package protocol + +import ( + "encoding/json" + "errors" +) + +// PayReqResponse is the response sent by the receiver to the sender to provide an invoice. +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 is information about the payment that the receiver will receive. Includes Final currency-related + // information for the payment. Required for UMA. + PaymentInfo *PayReqResponsePaymentInfo `json:"converted"` + // 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"` + // UmaMajorVersion is the major version of the UMA protocol that the receiver is using. Only used + // for serialization and deserialization. Not included in the JSON response. + UmaMajorVersion int `json:"umaMajorVersion"` +} + +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 { + Pubkey string `json:"pubkey"` + Path []struct { + Pubkey string `json:"pubkey"` + Fee int64 `json:"fee"` + Msatoshi int64 `json:"msatoshi"` + Channel string `json:"channel"` + } `json:"path"` +} + +type PayReqResponsePaymentInfo struct { + // Amount is the amount that the receiver will receive in the receiving currency not including fees. The amount is + // specified in the smallest unit of the currency (eg. cents for USD). + Amount *int64 `json:"amount"` + // CurrencyCode is the currency code that the receiver will receive for this payment. + CurrencyCode string `json:"currencyCode"` + // Multiplier is the conversion rate. It is the number of millisatoshis that the receiver will receive for 1 unit of + // the specified currency. In this context, this is just for convenience. The conversion rate is also baked into + // the invoice amount itself. + // `invoice amount = Amount * Multiplier + ExchangeFeesMillisatoshi` + Multiplier float64 `json:"multiplier"` + // Decimals is the number of digits after the decimal point for the receiving currency. For example, in USD, by + // convention, there are 2 digits for cents - $5.95. In this case, `Decimals` would be 2. This should align with the + // currency's `Decimals` field in the LNURLP response. It is included here for convenience. See + // [UMAD-04](/uma-04-local-currency.md) for details, edge cases, and examples. + Decimals int `json:"decimals"` + // ExchangeFeesMillisatoshi is the fees charged (in millisats) by the receiving VASP for this transaction. This is + // separate from the Multiplier. + ExchangeFeesMillisatoshi int64 `json:"fee"` +} + +type v0PayReqResponsePaymentInfo struct { + CurrencyCode string `json:"currencyCode"` + Multiplier float64 `json:"multiplier"` + Decimals int `json:"decimals"` + ExchangeFeesMillisatoshi int64 `json:"exchangeFeesMillisatoshi"` +} + +type v0PayReqResponse struct { + EncodedInvoice string `json:"pr"` + Routes []Route `json:"routes"` + PaymentInfo *v0PayReqResponsePaymentInfo `json:"paymentInfo"` + PayeeData *PayeeData `json:"payeeData"` + Disposable *bool `json:"disposable"` + SuccessAction *map[string]string `json:"successAction"` + Compliance *CompliancePayeeData `json:"compliance"` +} + +type v1PayReqResponse struct { + EncodedInvoice string `json:"pr"` + Routes []Route `json:"routes"` + PaymentInfo *PayReqResponsePaymentInfo `json:"converted"` + PayeeData *PayeeData `json:"payeeData"` + Disposable *bool `json:"disposable"` + SuccessAction *map[string]string `json:"successAction"` +} + +func (p *PayReqResponse) asV0() (*v0PayReqResponse, error) { + if p.UmaMajorVersion != 0 { + return nil, errors.New("not a v0 response") + } + compliance, err := p.PayeeData.Compliance() + if err != nil { + return nil, err + } + var v0PaymentInfo *v0PayReqResponsePaymentInfo + if p.PaymentInfo != nil { + v0PaymentInfo = &v0PayReqResponsePaymentInfo{ + CurrencyCode: p.PaymentInfo.CurrencyCode, + Multiplier: p.PaymentInfo.Multiplier, + Decimals: p.PaymentInfo.Decimals, + ExchangeFeesMillisatoshi: p.PaymentInfo.ExchangeFeesMillisatoshi, + } + } + return &v0PayReqResponse{ + EncodedInvoice: p.EncodedInvoice, + Routes: p.Routes, + PaymentInfo: v0PaymentInfo, + PayeeData: p.PayeeData, + Disposable: p.Disposable, + SuccessAction: p.SuccessAction, + Compliance: compliance, + }, nil +} + +func (p *PayReqResponse) asV1() *v1PayReqResponse { + if p.UmaMajorVersion != 1 { + return nil + } + return &v1PayReqResponse{ + EncodedInvoice: p.EncodedInvoice, + Routes: p.Routes, + PaymentInfo: p.PaymentInfo, + PayeeData: p.PayeeData, + Disposable: p.Disposable, + SuccessAction: p.SuccessAction, + } +} + +func (p *PayReqResponse) MarshalJSON() ([]byte, error) { + if p.UmaMajorVersion == 0 { + v0, err := p.asV0() + if err != nil { + return nil, err + } + return json.Marshal(v0) + } + return json.Marshal(p.asV1()) +} + +func (p *PayReqResponse) UnmarshalJSON(data []byte) error { + dataAsMap := make(map[string]interface{}) + err := json.Unmarshal(data, &dataAsMap) + if err != nil { + return err + } + umaVersion := 1 + if _, ok := dataAsMap["paymentInfo"]; ok { + umaVersion = 0 + } + if umaVersion == 0 { + var v0 v0PayReqResponse + err := json.Unmarshal(data, &v0) + if err != nil { + return err + } + var paymentInfo *PayReqResponsePaymentInfo + if v0.PaymentInfo != nil { + paymentInfo = &PayReqResponsePaymentInfo{ + CurrencyCode: v0.PaymentInfo.CurrencyCode, + Multiplier: v0.PaymentInfo.Multiplier, + Decimals: v0.PaymentInfo.Decimals, + ExchangeFeesMillisatoshi: v0.PaymentInfo.ExchangeFeesMillisatoshi, + } + } + if v0.Compliance != nil { + if v0.PayeeData == nil { + v0.PayeeData = &PayeeData{} + } + complianceMap, err := v0.Compliance.AsMap() + if err != nil { + return err + } + (*v0.PayeeData)["compliance"] = complianceMap + } + p.UmaMajorVersion = 0 + p.EncodedInvoice = v0.EncodedInvoice + p.Routes = v0.Routes + p.PaymentInfo = paymentInfo + p.PayeeData = v0.PayeeData + p.Disposable = v0.Disposable + p.SuccessAction = v0.SuccessAction + return nil + } + + var v1 v1PayReqResponse + err = json.Unmarshal(data, &v1) + if err != nil { + return err + } + p.UmaMajorVersion = 1 + p.EncodedInvoice = v1.EncodedInvoice + p.Routes = v1.Routes + p.PaymentInfo = v1.PaymentInfo + p.PayeeData = v1.PayeeData + p.Disposable = v1.Disposable + p.SuccessAction = v1.SuccessAction + return nil +} diff --git a/uma/protocol/post_transaction_callback.go b/uma/protocol/post_transaction_callback.go new file mode 100644 index 0000000..d538254 --- /dev/null +++ b/uma/protocol/post_transaction_callback.go @@ -0,0 +1,44 @@ +package protocol + +import ( + "errors" + "strconv" + "strings" +) + +// PostTransactionCallback is sent between VASPs after the payment is complete. +type PostTransactionCallback struct { + // Utxos is a list of utxo/amounts corresponding to the VASPs channels. + Utxos []UtxoWithAmount `json:"utxos"` + // VaspDomain is the domain of the VASP that is sending the callback. + // It will be used by the VASP to fetch the public keys of its counterparty. + VaspDomain *string `json:"vaspDomain"` + // Signature is the base64-encoded signature of sha256(Nonce|Timestamp). + Signature *string `json:"signature"` + // Nonce is a random string that is used to prevent replay attacks. + Nonce *string `json:"signatureNonce"` + // Timestamp is the unix timestamp of when the request was sent. Used in the signature. + Timestamp *int64 `json:"signatureTimestamp"` +} + +// UtxoWithAmount is a pair of utxo and amount transferred over that corresponding channel. +// It can be used to register payment for KYT. +type UtxoWithAmount struct { + // Utxo The utxo of the channel over which the payment went through in the format of :. + Utxo string `json:"utxo"` + + // Amount The amount of funds transferred in the payment in mSats. + Amount int64 `json:"amountMsats"` +} + +func (c *PostTransactionCallback) SignablePayload() (*[]byte, error) { + if c.Nonce == nil || c.Timestamp == nil { + return nil, errors.New("nonce and timestamp must be set") + } + payloadString := strings.Join([]string{ + *c.Nonce, + strconv.FormatInt(*c.Timestamp, 10), + }, "|") + payload := []byte(payloadString) + return &payload, nil +} diff --git a/uma/protocol/pub_key_response.go b/uma/protocol/pub_key_response.go new file mode 100644 index 0000000..88e9758 --- /dev/null +++ b/uma/protocol/pub_key_response.go @@ -0,0 +1,108 @@ +package protocol + +import ( + "encoding/hex" + "encoding/json" + "errors" + "github.com/uma-universal-money-address/uma-go-sdk/uma/utils" +) + +// PubKeyResponse is sent from a VASP to another VASP to provide its public keys. +// It is the response to GET requests at `/.well-known/lnurlpubkey`. +type PubKeyResponse struct { + // SigningCertChain is the PEM-encoded certificate chain used to verify signatures from a VASP. + SigningCertChain *string + // EncryptionCertChain is the PEM-encoded certificate chain used to encrypt TR info sent to a VASP. + EncryptionCertChain *string + // SigningPubKeyHex is used to verify signatures from a VASP. Hex-encoded byte array. + SigningPubKeyHex *string + // EncryptionPubKeyHex is used to encrypt TR info sent to a VASP. Hex-encoded byte array. + EncryptionPubKeyHex *string + // ExpirationTimestamp [Optional] Seconds since epoch at which these pub keys must be refreshed. + // They can be safely cached until this expiration (or forever if null). + ExpirationTimestamp *int64 +} + +func (r *PubKeyResponse) SigningPubKey() ([]byte, error) { + if r.SigningCertChain != nil { + publicKey, err := utils.ExtractPubkeyFromPemCertificateChain(r.SigningCertChain) + if err != nil { + return nil, err + } + return publicKey.SerializeUncompressed(), nil + } else if r.SigningPubKeyHex != nil { + publicKey, err := hex.DecodeString(*r.SigningPubKeyHex) + if err != nil { + return nil, err + } + return publicKey, nil + } else { + return nil, errors.New("signingPubKeyHex is nil") + } +} + +func (r *PubKeyResponse) EncryptionPubKey() ([]byte, error) { + if r.EncryptionCertChain != nil { + publicKey, err := utils.ExtractPubkeyFromPemCertificateChain(r.EncryptionCertChain) + if err != nil { + return nil, err + } + return publicKey.SerializeUncompressed(), nil + } else if r.EncryptionPubKeyHex != nil { + publicKey, err := hex.DecodeString(*r.EncryptionPubKeyHex) + if err != nil { + return nil, err + } + return publicKey, nil + } else { + return nil, errors.New("encryptionPubKeyHex is nil") + } +} + +func (r *PubKeyResponse) MarshalJSON() ([]byte, error) { + signingCertChainHexDer, err := utils.ConvertPemCertificateChainToHexEncodedDer(r.SigningCertChain) + if err != nil { + return nil, err + } + encryptionCertChainHexDer, err := utils.ConvertPemCertificateChainToHexEncodedDer(r.EncryptionCertChain) + if err != nil { + return nil, err + } + m := pubKeyResponseJson{ + &signingCertChainHexDer, + &encryptionCertChainHexDer, + r.SigningPubKeyHex, + r.EncryptionPubKeyHex, + r.ExpirationTimestamp, + } + return json.Marshal(m) +} + +func (r *PubKeyResponse) UnmarshalJSON(data []byte) error { + var temp pubKeyResponseJson + if err := json.Unmarshal(data, &temp); err != nil { + return err + } + signingCertChainPem, err := utils.ConvertHexEncodedDerToPemCertChain(temp.SigningCertChainHexDer) + if err != nil { + return err + } + encryptionCertChainPem, err := utils.ConvertHexEncodedDerToPemCertChain(temp.EncryptionCertChainHexDer) + if err != nil { + return err + } + r.SigningCertChain = signingCertChainPem + r.EncryptionCertChain = encryptionCertChainPem + r.SigningPubKeyHex = temp.SigningPubKeyHex + r.EncryptionPubKeyHex = temp.EncryptionPubKeyHex + r.ExpirationTimestamp = temp.ExpirationTimestamp + return nil +} + +type pubKeyResponseJson struct { + SigningCertChainHexDer *[]string `json:"signingCertChain"` + EncryptionCertChainHexDer *[]string `json:"encryptionCertChain"` + SigningPubKeyHex *string `json:"signingPubKey"` + EncryptionPubKeyHex *string `json:"encryptionPubKey"` + ExpirationTimestamp *int64 `json:"expirationTimestamp"` +} diff --git a/uma/public_key_cache.go b/uma/public_key_cache.go index 2dd46a7..6dbec2b 100644 --- a/uma/public_key_cache.go +++ b/uma/public_key_cache.go @@ -1,16 +1,19 @@ package uma -import "time" +import ( + "github.com/uma-universal-money-address/uma-go-sdk/uma/protocol" + "time" +) // PublicKeyCache is an interface for a cache of public keys for other VASPs. // // Implementations of this interface should be thread-safe. type PublicKeyCache interface { // FetchPublicKeyForVasp fetches the public key entry for a VASP if in the cache, otherwise returns nil. - FetchPublicKeyForVasp(vaspDomain string) *PubKeyResponse + FetchPublicKeyForVasp(vaspDomain string) *protocol.PubKeyResponse // AddPublicKeyForVasp adds a public key entry for a VASP to the cache. - AddPublicKeyForVasp(vaspDomain string, pubKey *PubKeyResponse) + AddPublicKeyForVasp(vaspDomain string, pubKey *protocol.PubKeyResponse) // RemovePublicKeyForVasp removes a public key for a VASP from the cache. RemovePublicKeyForVasp(vaspDomain string) @@ -20,16 +23,16 @@ type PublicKeyCache interface { } type InMemoryPublicKeyCache struct { - cache map[string]*PubKeyResponse + cache map[string]*protocol.PubKeyResponse } func NewInMemoryPublicKeyCache() *InMemoryPublicKeyCache { return &InMemoryPublicKeyCache{ - cache: make(map[string]*PubKeyResponse), + cache: make(map[string]*protocol.PubKeyResponse), } } -func (c *InMemoryPublicKeyCache) FetchPublicKeyForVasp(vaspDomain string) *PubKeyResponse { +func (c *InMemoryPublicKeyCache) FetchPublicKeyForVasp(vaspDomain string) *protocol.PubKeyResponse { entry := c.cache[vaspDomain] if entry == nil || (entry.ExpirationTimestamp != nil && time.Unix(*entry.ExpirationTimestamp, 0).Before(time.Now())) { return nil @@ -37,7 +40,7 @@ func (c *InMemoryPublicKeyCache) FetchPublicKeyForVasp(vaspDomain string) *PubKe return entry } -func (c *InMemoryPublicKeyCache) AddPublicKeyForVasp(vaspDomain string, pubKey *PubKeyResponse) { +func (c *InMemoryPublicKeyCache) AddPublicKeyForVasp(vaspDomain string, pubKey *protocol.PubKeyResponse) { c.cache[vaspDomain] = pubKey } @@ -46,5 +49,5 @@ func (c *InMemoryPublicKeyCache) RemovePublicKeyForVasp(vaspDomain string) { } func (c *InMemoryPublicKeyCache) Clear() { - c.cache = make(map[string]*PubKeyResponse) + c.cache = make(map[string]*protocol.PubKeyResponse) } diff --git a/uma/test/protocol_test.go b/uma/test/protocol_test.go new file mode 100644 index 0000000..0ecc324 --- /dev/null +++ b/uma/test/protocol_test.go @@ -0,0 +1,253 @@ +package uma_test + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" + "github.com/uma-universal-money-address/uma-go-sdk/uma" + umaprotocol "github.com/uma-universal-money-address/uma-go-sdk/uma/protocol" +) + +func TestParseV0Currency(t *testing.T) { + currency := umaprotocol.Currency{ + Code: "USD", + Symbol: "$", + Name: "US Dollar", + MillisatoshiPerUnit: 12345, + Convertible: umaprotocol.ConvertibleCurrency{ + MinSendable: 100, + MaxSendable: 100000000, + }, + Decimals: 2, + UmaMajorVersion: 0, + } + + currencyJson, err := currency.MarshalJSON() + require.NoError(t, err) + currencyJsonMap := make(map[string]interface{}) + err = json.Unmarshal(currencyJson, ¤cyJsonMap) + require.NoError(t, err) + require.Equal(t, "USD", currencyJsonMap["code"]) + require.Equal(t, "$", currencyJsonMap["symbol"]) + require.Equal(t, "US Dollar", currencyJsonMap["name"]) + require.Equal(t, 12345.0, currencyJsonMap["multiplier"]) + require.Equal(t, 100.0, currencyJsonMap["minSendable"]) + require.Equal(t, 100000000.0, currencyJsonMap["maxSendable"]) + require.Equal(t, 2.0, currencyJsonMap["decimals"]) + + reserializedCurrency := umaprotocol.Currency{} + err = json.Unmarshal(currencyJson, &reserializedCurrency) + require.NoError(t, err) + require.Equal(t, currency, reserializedCurrency) +} + +func TestV0LnurlpResponse(t *testing.T) { + currencies := []umaprotocol.Currency{ + { + Code: "USD", + Symbol: "$", + Name: "US Dollar", + MillisatoshiPerUnit: 12345, + Convertible: umaprotocol.ConvertibleCurrency{ + MinSendable: 100, + MaxSendable: 100000000, + }, + Decimals: 2, + UmaMajorVersion: 0, + }, + } + umaVersion := "0.3" + lnurlpResponse := umaprotocol.LnurlpResponse{ + Callback: "https://example.com/lnurlp", + Tag: "withdrawRequest", + MinSendable: 1000, + MaxSendable: 1000000, + Currencies: ¤cies, + EncodedMetadata: "metadata", + UmaVersion: &umaVersion, + } + + lnurlpResponseJson, err := json.Marshal(lnurlpResponse) + require.NoError(t, err) + lnurlpResponseJsonMap := make(map[string]interface{}) + err = json.Unmarshal(lnurlpResponseJson, &lnurlpResponseJsonMap) + require.NoError(t, err) + require.Equal(t, "https://example.com/lnurlp", lnurlpResponseJsonMap["callback"]) + require.Equal(t, "withdrawRequest", lnurlpResponseJsonMap["tag"]) + require.Equal(t, 1000.0, lnurlpResponseJsonMap["minSendable"]) + require.Equal(t, 1000000.0, lnurlpResponseJsonMap["maxSendable"]) + require.Equal(t, "metadata", lnurlpResponseJsonMap["metadata"]) + require.Equal(t, "0.3", lnurlpResponseJsonMap["umaVersion"]) + require.Equal(t, 100.0, lnurlpResponseJsonMap["currencies"].([]interface{})[0].(map[string]interface{})["minSendable"]) + require.Equal(t, 100000000.0, lnurlpResponseJsonMap["currencies"].([]interface{})[0].(map[string]interface{})["maxSendable"]) + + reserializedLnurlpResponse := umaprotocol.LnurlpResponse{} + err = json.Unmarshal(lnurlpResponseJson, &reserializedLnurlpResponse) + require.NoError(t, err) + require.Equal(t, lnurlpResponse, reserializedLnurlpResponse) +} + +func TestV0PayRequest(t *testing.T) { + payerData := umaprotocol.PayerData{ + "identifier": "$foo@bar.com", + "name": "Foo Bar", + "email": "email@themail.com", + } + currencyCode := "USD" + comment := "comment" + payRequest := umaprotocol.PayRequest{ + ReceivingCurrencyCode: ¤cyCode, + Amount: 1000, + PayerData: &payerData, + UmaMajorVersion: 0, + Comment: &comment, + } + + payRequestJson, err := payRequest.MarshalJSON() + require.NoError(t, err) + payRequestJsonMap := make(map[string]interface{}) + err = json.Unmarshal(payRequestJson, &payRequestJsonMap) + require.NoError(t, err) + require.Equal(t, "USD", payRequestJsonMap["currency"]) + require.Equal(t, 1000.0, payRequestJsonMap["amount"]) + require.Equal(t, "$foo@bar.com", payRequestJsonMap["payerData"].(map[string]interface{})["identifier"]) + require.Equal(t, "Foo Bar", payRequestJsonMap["payerData"].(map[string]interface{})["name"]) + require.Equal(t, "email@themail.com", payRequestJsonMap["payerData"].(map[string]interface{})["email"]) + require.Equal(t, "comment", payRequestJsonMap["comment"]) + + reserializedPayRequest := umaprotocol.PayRequest{} + err = json.Unmarshal(payRequestJson, &reserializedPayRequest) + require.NoError(t, err) + require.Equal(t, payRequest, reserializedPayRequest) +} + +func TestParseV1PayReq(t *testing.T) { + payerData := umaprotocol.PayerData{ + "identifier": "$foo@bar.com", + "name": "Foo Bar", + "email": "email@themail.com", + } + currencyCode := "USD" + comment := "comment" + payRequest := umaprotocol.PayRequest{ + ReceivingCurrencyCode: ¤cyCode, + SendingAmountCurrencyCode: ¤cyCode, + Amount: 1000, + PayerData: &payerData, + UmaMajorVersion: 1, + Comment: &comment, + } + + payRequestJson, err := payRequest.MarshalJSON() + require.NoError(t, err) + payRequestJsonMap := make(map[string]interface{}) + err = json.Unmarshal(payRequestJson, &payRequestJsonMap) + require.NoError(t, err) + require.Equal(t, "USD", payRequestJsonMap["convert"]) + require.Equal(t, "1000.USD", payRequestJsonMap["amount"]) + require.Equal(t, "$foo@bar.com", payRequestJsonMap["payerData"].(map[string]interface{})["identifier"]) + require.Equal(t, "Foo Bar", payRequestJsonMap["payerData"].(map[string]interface{})["name"]) + require.Equal(t, "email@themail.com", payRequestJsonMap["payerData"].(map[string]interface{})["email"]) + require.Equal(t, "comment", payRequestJsonMap["comment"]) + + reserializedPayRequest := umaprotocol.PayRequest{} + err = json.Unmarshal(payRequestJson, &reserializedPayRequest) + require.NoError(t, err) + require.Equal(t, payRequest, reserializedPayRequest) +} + +func TestParseV0PayReqResponse(t *testing.T) { + payReqRespJson := `{ + "pr": "lnbc1000n1p0u3", + "routes": [], + "compliance": { + "nodePubKey": "02", + "utxos": ["txid"], + "utxoCallback": "https://example.com/utxo" + }, + "paymentInfo": { + "currencyCode": "USD", + "multiplier": 1000, + "decimals": 2, + "exchangeFeesMillisatoshi": 1000 + } + }` + payReqResp := umaprotocol.PayReqResponse{} + err := json.Unmarshal([]byte(payReqRespJson), &payReqResp) + require.NoError(t, err) + require.Equal(t, "lnbc1000n1p0u3", payReqResp.EncodedInvoice) + require.Equal(t, "USD", payReqResp.PaymentInfo.CurrencyCode) + require.Equal(t, 1000.0, payReqResp.PaymentInfo.Multiplier) + require.Equal(t, 2, payReqResp.PaymentInfo.Decimals) + require.Equal(t, int64(1000), payReqResp.PaymentInfo.ExchangeFeesMillisatoshi) + compliance, err := payReqResp.PayeeData.Compliance() + require.NoError(t, err) + require.NotNilf(t, compliance, "compliance is nil") + require.Equal(t, "02", *(compliance.NodePubKey)) + require.Equal(t, []string{"txid"}, compliance.Utxos) + require.Equal(t, "https://example.com/utxo", *(compliance.UtxoCallback)) + require.Equal(t, 0, payReqResp.UmaMajorVersion) +} + +func TestEncodeAndParsePubKeyResponse(t *testing.T) { + pubKeyHex := "04419c5467ea563f0010fd614f85e885ac99c21b8e8d416241175fdd5efd2244fe907e2e6fa3dd6631b1b17cd28798da8d882a34c4776d44cc4090781c7aadea1b" + pemCertChain := `-----BEGIN CERTIFICATE----- +MIIB1zCCAXygAwIBAgIUGN3ihBj1RnKoeTM/auDFnNoThR4wCgYIKoZIzj0EAwIw +QjELMAkGA1UEBhMCVVMxEzARBgNVBAgMCmNhbGlmb3JuaWExDjAMBgNVBAcMBWxv +cyBhMQ4wDAYDVQQKDAVsaWdodDAeFw0yNDAzMDUyMTAzMTJaFw0yNDAzMTkyMTAz +MTJaMEIxCzAJBgNVBAYTAlVTMRMwEQYDVQQIDApjYWxpZm9ybmlhMQ4wDAYDVQQH +DAVsb3MgYTEOMAwGA1UECgwFbGlnaHQwVjAQBgcqhkjOPQIBBgUrgQQACgNCAARB +nFRn6lY/ABD9YU+F6IWsmcIbjo1BYkEXX91e/SJE/pB+Lm+j3WYxsbF80oeY2o2I +KjTEd21EzECQeBx6reobo1MwUTAdBgNVHQ4EFgQUU87LnQdiP6XIE6LoKU1PZnbt +bMwwHwYDVR0jBBgwFoAUU87LnQdiP6XIE6LoKU1PZnbtbMwwDwYDVR0TAQH/BAUw +AwEB/zAKBggqhkjOPQQDAgNJADBGAiEAvsrvoeo3rbgZdTHxEUIgP0ArLyiO34oz +NlwL4gk5GpgCIQCvRx4PAyXNV9T6RRE+3wFlqwluOc/pPOjgdRw/wpoNPQ== +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIICdjCCAV6gAwIBAgIUAekCcU1Qhjo2Y6L2Down9BLdfdUwDQYJKoZIhvcNAQEL +BQAwNDELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAmNhMQwwCgYDVQQHDANsb3MxCjAI +BgNVBAoMAWEwHhcNMjQwMzA4MDEwNTU3WhcNMjUwMzA4MDEwNTU3WjBAMQswCQYD +VQQGEwJVUzELMAkGA1UECAwCY2ExDDAKBgNVBAcMA2xvczEKMAgGA1UECgwBYTEK +MAgGA1UECwwBYTBWMBAGByqGSM49AgEGBSuBBAAKA0IABJ11ZAQKylgIzZmuI5NE ++DyZ9BUDZhxUPSxTxl+s1am+Lxzr9D7wlwOiiqCYHFWpL6lkCmJcCC06P3RyzXIT +KmyjQjBAMB0GA1UdDgQWBBRXgW6xGB3+mTSSUKlhSiu3LS+TKTAfBgNVHSMEGDAW +gBTFmyv7+YDpK0WAOHJYAzjynmWsMDANBgkqhkiG9w0BAQsFAAOCAQEAFVAA3wo+ +Hi/k+OWO/1CFqIRV/0cA8F05sBMiKVA11xB6I1y54aUV4R0jN76fOiN1jnZqTRnM +G8rZUfQgE/LPVbb1ERHQfd8yaeI+TerKdPkMseu/jnvI+dDJfQdsY7iaa7NPO0dm +t8Nz75cYW8kYuDaq0Hb6uGsywf9LGO/VjrDhyiRxmZ1Oq4JxQmLuh5SDcPfqHTR3 +VbMC1b7eVXaA9O2qYS36zv8cCUSUl5sOSwM6moaFN+xLtVNJ6ZhKPNS2Gd8znhzZ +AQZcDDpXBO6ORNbhVk5A3X6eQX4Ek1HBTa3pcSUQomYAA9TIuVzL6DSot5GWS8Ek +usLY8crt6ys3KQ== +-----END CERTIFICATE----- +` + pubKeyResponse, err := uma.GetPubKeyResponse(pemCertChain, pemCertChain, nil) + require.NoError(t, err) + + pubKeyResponseJson, err := pubKeyResponse.MarshalJSON() + require.NoError(t, err) + pubKeyResponseJsonMap := make(map[string]interface{}) + err = json.Unmarshal(pubKeyResponseJson, &pubKeyResponseJsonMap) + require.NoError(t, err) + + reserializedPubKeyResponse := umaprotocol.PubKeyResponse{} + err = json.Unmarshal(pubKeyResponseJson, &reserializedPubKeyResponse) + require.NoError(t, err) + require.Equal(t, *pubKeyResponse, reserializedPubKeyResponse) + + keysOnlyPubKeyResponse := umaprotocol.PubKeyResponse{ + SigningPubKeyHex: &pubKeyHex, + EncryptionPubKeyHex: &pubKeyHex, + } + + pubKeyResponseJson, err = keysOnlyPubKeyResponse.MarshalJSON() + require.NoError(t, err) + pubKeyResponseJsonMap = make(map[string]interface{}) + err = json.Unmarshal(pubKeyResponseJson, &pubKeyResponseJsonMap) + require.NoError(t, err) + + reserializedPubKeyResponse = umaprotocol.PubKeyResponse{} + err = json.Unmarshal(pubKeyResponseJson, &reserializedPubKeyResponse) + require.NoError(t, err) + require.Equal(t, keysOnlyPubKeyResponse, reserializedPubKeyResponse) +} diff --git a/uma/test/uma_test.go b/uma/test/uma_test.go index a07084e..868c851 100644 --- a/uma/test/uma_test.go +++ b/uma/test/uma_test.go @@ -8,6 +8,9 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/uma-universal-money-address/uma-go-sdk/uma" + umaprotocol "github.com/uma-universal-money-address/uma-go-sdk/uma/protocol" + "github.com/uma-universal-money-address/uma-go-sdk/uma/utils" + "math" "net/url" "strconv" "testing" @@ -17,16 +20,21 @@ import ( func TestParse(t *testing.T) { expectedTime, _ := time.Parse(time.RFC3339, "2023-07-27T22:46:08Z") timeSec := expectedTime.Unix() - expectedQuery := uma.LnurlpRequest{ + signature := "signature" + isSubjectToTravelRule := true + nonce := "12345" + vaspDomain := "vasp1.com" + umaVersion := "1.0" + expectedQuery := umaprotocol.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 { @@ -36,54 +44,54 @@ 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)) + require.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)) + require.False(t, uma.IsUmaLnurlpQuery(*urlObj)) urlString = "https://vasp2.com/.well-known/lnurlp/bob?signature=signature&nonce=12345&vaspDomain=vasp1.com&isSubjectToTravelRule=true×tamp=12345678" urlObj, _ = url.Parse(urlString) - assert.False(t, uma.IsUmaLnurlpQuery(*urlObj)) + require.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)) + require.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)) + require.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)) + require.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)) + require.False(t, uma.IsUmaLnurlpQuery(*urlObj)) urlString = "https://vasp2.com/.well-known/lnurlp/bob" urlObj, _ = url.Parse(urlString) - assert.False(t, uma.IsUmaLnurlpQuery(*urlObj)) + require.False(t, uma.IsUmaLnurlpQuery(*urlObj)) } 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)) + require.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)) + require.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)) + require.False(t, uma.IsUmaLnurlpQuery(*urlObj)) } func TestIsUmaQueryUnsupportedVersion(t *testing.T) { @@ -104,8 +112,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()) + require.Equal(t, *query.UmaVersion, uma.UmaProtocolVersion) + err = uma.VerifyUmaLnurlpQuerySignature(*query.AsUmaRequest(), getPubKeyResponse(privateKey), getNonceCache()) require.NoError(t, err) } @@ -119,17 +127,25 @@ func TestParseLnurlpRequestUnsupportedVersion(t *testing.T) { var unsupportedVersionError uma.UnsupportedVersionError require.ErrorAs(t, err, &unsupportedVersionError) require.Equal(t, unsupportedVersionError.UnsupportedVersion, version) - require.Equal(t, unsupportedVersionError.SupportedMajorVersions, []int{0}) + require.Equal(t, unsupportedVersionError.SupportedMajorVersions, []int{1, 0}) } func TestSignAndVerifyLnurlpRequestInvalidSignature(t *testing.T) { + invalidPubKeyHex := "invalid pub key" + invalidPubKeyResponse := umaprotocol.PubKeyResponse{ + SigningCertChain: nil, + EncryptionCertChain: nil, + SigningPubKeyHex: &invalidPubKeyHex, + EncryptionPubKeyHex: &invalidPubKeyHex, + ExpirationTimestamp: nil, + } privateKey, err := secp256k1.GeneratePrivateKey() require.NoError(t, err) queryUrl, err := uma.GetSignedLnurlpRequestUrl(privateKey.Serialize(), "$bob@vasp2.com", "vasp1.com", true, nil) 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(), invalidPubKeyResponse, getNonceCache()) require.Error(t, err) } @@ -141,7 +157,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(), getPubKeyResponse(privateKey), uma.NewInMemoryNonceCache(tomorrow)) require.Error(t, err) } @@ -153,9 +169,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(), getPubKeyResponse(privateKey), nonceCache) require.Error(t, err) } @@ -164,34 +180,41 @@ 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 := umaprotocol.KycStatusVerified response, err := uma.GetLnurlpResponse( request, - receiverSigningPrivateKey.Serialize(), - true, "https://vasp2.com/api/lnurl/payreq/$bob", metadata, 1, 10_000_000, - uma.PayerDataOptions{ - NameRequired: false, - EmailRequired: false, - ComplianceRequired: true, + &serializedPrivateKey, + &isSubjectToTravelRule, + &umaprotocol.CounterPartyDataOptions{ + "name": umaprotocol.CounterPartyDataOption{Mandatory: false}, + "email": umaprotocol.CounterPartyDataOption{Mandatory: false}, + "compliance": umaprotocol.CounterPartyDataOption{Mandatory: true}, }, - []uma.Currency{ + &[]umaprotocol.Currency{ { Code: "USD", Name: "US Dollar", Symbol: "$", MillisatoshiPerUnit: 34_150, - MinSendable: 1, - MaxSendable: 10_000_000, - Decimals: 2, + Convertible: umaprotocol.ConvertibleCurrency{ + MinSendable: 1, + MaxSendable: 10_000_000, + }, + Decimals: 2, }, }, - uma.KycStatusVerified, + &kycStatus, + nil, + nil, ) require.NoError(t, err) responseJson, err := json.Marshal(response) @@ -199,7 +222,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(), getPubKeyResponse(receiverSigningPrivateKey), getNonceCache()) require.NoError(t, err) } @@ -211,39 +234,52 @@ func TestPayReqCreationAndParsing(t *testing.T) { trInfo := "some TR info for VASP2" ivmsVersion := "101.1" - trFormat := uma.TravelRuleFormat{ + trFormat := umaprotocol.TravelRuleFormat{ Type: "IVMS", Version: &ivmsVersion, } - payreq, err := uma.GetPayRequest( + payreq, err := uma.GetUmaPayRequest( + 1000, receiverEncryptionPrivateKey.PubKey().SerializeUncompressed(), senderSigningPrivateKey.Serialize(), "USD", - 1000, + true, "$alice@vasp1.com", + 1, nil, nil, &trInfo, &trFormat, - uma.KycStatusVerified, + umaprotocol.KycStatusVerified, nil, nil, "/api/lnurl/utxocallback?txid=1234", + nil, + nil, ) require.NoError(t, err) payreqJson, err := json.Marshal(payreq) require.NoError(t, err) + // Verify the encoding format: + parsedJsonMap := make(map[string]interface{}) + err = json.Unmarshal(payreqJson, &parsedJsonMap) + require.NoError(t, err) + require.Equal(t, parsedJsonMap["amount"], "1000.USD") + require.Equal(t, parsedJsonMap["convert"], "USD") + payreq, err = uma.ParsePayRequest(payreqJson) require.NoError(t, err) - err = uma.VerifyPayReqSignature(payreq, senderSigningPrivateKey.PubKey().SerializeUncompressed(), getNonceCache()) + err = uma.VerifyPayReqSignature(payreq, getPubKeyResponse(senderSigningPrivateKey), getNonceCache()) require.NoError(t, err) - require.Equal(t, payreq.PayerData.Compliance.TravelRuleFormat, &trFormat) + complianceData, err := payreq.PayerData.Compliance() + require.NoError(t, err) + require.Equal(t, complianceData.TravelRuleFormat, &trFormat) - encryptedTrInfo := payreq.PayerData.Compliance.EncryptedTravelRuleInfo + encryptedTrInfo := complianceData.EncryptedTravelRuleInfo require.NotNil(t, encryptedTrInfo) encryptedTrInfoBytes, err := hex.DecodeString(*encryptedTrInfo) @@ -251,12 +287,60 @@ func TestPayReqCreationAndParsing(t *testing.T) { eciesPrivKey := eciesgo.NewPrivateKeyFromBytes(receiverEncryptionPrivateKey.Serialize()) decryptedTrInfo, err := eciesgo.Decrypt(eciesPrivKey, encryptedTrInfoBytes) require.NoError(t, err) - assert.Equal(t, trInfo, string(decryptedTrInfo)) + require.Equal(t, trInfo, string(decryptedTrInfo)) +} + +func TestMsatsPayReqCreationAndParsing(t *testing.T) { + senderSigningPrivateKey, err := secp256k1.GeneratePrivateKey() + require.NoError(t, err) + receiverEncryptionPrivateKey, err := secp256k1.GeneratePrivateKey() + require.NoError(t, err) + + trInfo := "some TR info for VASP2" + ivmsVersion := "101.1" + trFormat := umaprotocol.TravelRuleFormat{ + Type: "IVMS", + Version: &ivmsVersion, + } + payreq, err := uma.GetUmaPayRequest( + 1000, + receiverEncryptionPrivateKey.PubKey().SerializeUncompressed(), + senderSigningPrivateKey.Serialize(), + "USD", + false, + "$alice@vasp1.com", + 1, + nil, + nil, + &trInfo, + &trFormat, + umaprotocol.KycStatusVerified, + nil, + nil, + "/api/lnurl/utxocallback?txid=1234", + nil, + nil, + ) + require.NoError(t, err) + + payreqJson, err := json.Marshal(payreq) + require.NoError(t, err) + + // Verify the encoding format: + parsedJsonMap := make(map[string]interface{}) + err = json.Unmarshal(payreqJson, &parsedJsonMap) + require.NoError(t, err) + require.Equal(t, parsedJsonMap["amount"], "1000") + require.Equal(t, parsedJsonMap["convert"], "USD") + + payreq, err = uma.ParsePayRequest(payreqJson) + require.NoError(t, err) + require.NotNil(t, payreq) } type FakeInvoiceCreator struct{} -func (f *FakeInvoiceCreator) CreateUmaInvoice(int64, string) (*string, error) { +func (f *FakeInvoiceCreator) CreateInvoice(int64, string) (*string, error) { encodedInvoice := "lnbcrt100n1p0z9j" return &encodedInvoice, nil } @@ -266,40 +350,257 @@ func TestPayReqResponseAndParsing(t *testing.T) { require.NoError(t, err) receiverEncryptionPrivateKey, err := secp256k1.GeneratePrivateKey() require.NoError(t, err) + receiverSigningPrivateKey, err := secp256k1.GeneratePrivateKey() + require.NoError(t, err) trInfo := "some TR info for VASP2" - payreq, err := uma.GetPayRequest( + payeeOptions := umaprotocol.CounterPartyDataOptions{ + "identifier": umaprotocol.CounterPartyDataOption{ + Mandatory: true, + }, + "name": umaprotocol.CounterPartyDataOption{ + Mandatory: false, + }, + } + payreq, err := uma.GetUmaPayRequest( + 1000, receiverEncryptionPrivateKey.PubKey().SerializeUncompressed(), senderSigningPrivateKey.Serialize(), "USD", - 1000, + true, + "$alice@vasp1.com", + 1, + nil, + nil, + &trInfo, + nil, + umaprotocol.KycStatusVerified, + nil, + nil, + "/api/lnurl/utxocallback?txid=1234", + &payeeOptions, + nil, + ) + require.NoError(t, err) + client := &FakeInvoiceCreator{} + metadata, err := createMetadataForBob() + require.NoError(t, err) + payeeData := umaprotocol.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, + client, + metadata, + &receivingCurrencyCode, + &receivingCurrencyDecimals, + &conversionRate, + &fee, + &[]string{"abcdef12345"}, + nil, + &utxoCallback, + &payeeData, + &serializedPrivateKey, + &payeeIdentifier, + nil, + nil, + ) + require.NoError(t, err) + require.Equal(t, payreqResponse.PaymentInfo.Amount, &payreq.Amount) + 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) + 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, + getPubKeyResponse(receiverSigningPrivateKey), + getNonceCache(), + "$alice@vasp1.com", + "$bob@vasp2.com", + ) + require.NoError(t, err) +} + +func TestMsatsPayReqResponseAndParsing(t *testing.T) { + senderSigningPrivateKey, err := secp256k1.GeneratePrivateKey() + require.NoError(t, err) + receiverEncryptionPrivateKey, err := secp256k1.GeneratePrivateKey() + require.NoError(t, err) + receiverSigningPrivateKey, err := secp256k1.GeneratePrivateKey() + require.NoError(t, err) + + trInfo := "some TR info for VASP2" + payeeOptions := umaprotocol.CounterPartyDataOptions{ + "identifier": umaprotocol.CounterPartyDataOption{ + Mandatory: true, + }, + "name": umaprotocol.CounterPartyDataOption{ + Mandatory: false, + }, + } + payreq, err := uma.GetUmaPayRequest( + 1_000_000, + receiverEncryptionPrivateKey.PubKey().SerializeUncompressed(), + senderSigningPrivateKey.Serialize(), + "USD", + false, "$alice@vasp1.com", + 1, nil, nil, &trInfo, nil, - uma.KycStatusVerified, + umaprotocol.KycStatusVerified, nil, nil, "/api/lnurl/utxocallback?txid=1234", + &payeeOptions, + nil, ) require.NoError(t, err) client := &FakeInvoiceCreator{} metadata, err := createMetadataForBob() require.NoError(t, err) + payeeData := umaprotocol.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, + &receivingCurrencyCode, + &receivingCurrencyDecimals, + &conversionRate, + &fee, + &[]string{"abcdef12345"}, + nil, + &utxoCallback, + &payeeData, + &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) + + payreqResponseJson, err := json.Marshal(payreqResponse) + require.NoError(t, err) + + parsedResponse, err := uma.ParsePayReqResponse(payreqResponseJson) + require.NoError(t, err) + require.Equal(t, payreqResponse, parsedResponse) + + err = uma.VerifyPayReqResponseSignature( + parsedResponse, + getPubKeyResponse(receiverSigningPrivateKey), + getNonceCache(), + "$alice@vasp1.com", + "$bob@vasp2.com", + ) + require.NoError(t, err) +} + +func TestV0PayReqResponseAndParsing(t *testing.T) { + senderSigningPrivateKey, err := secp256k1.GeneratePrivateKey() + require.NoError(t, err) + receiverEncryptionPrivateKey, err := secp256k1.GeneratePrivateKey() + require.NoError(t, err) + receiverSigningPrivateKey, err := secp256k1.GeneratePrivateKey() + require.NoError(t, err) + + trInfo := "some TR info for VASP2" + payeeOptions := umaprotocol.CounterPartyDataOptions{ + "identifier": umaprotocol.CounterPartyDataOption{ + Mandatory: true, + }, + "name": umaprotocol.CounterPartyDataOption{ + Mandatory: false, + }, + } + payreq, err := uma.GetUmaPayRequest( + 1_000_000, + receiverEncryptionPrivateKey.PubKey().SerializeUncompressed(), + senderSigningPrivateKey.Serialize(), "USD", - 2, - 24_150, - 100_000, - []string{"abcdef12345"}, + false, + "$alice@vasp1.com", + 0, + nil, + nil, + &trInfo, + nil, + umaprotocol.KycStatusVerified, + nil, nil, "/api/lnurl/utxocallback?txid=1234", + &payeeOptions, + nil, + ) + require.NoError(t, err) + client := &FakeInvoiceCreator{} + metadata, err := createMetadataForBob() + require.NoError(t, err) + payeeData := umaprotocol.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, + client, + metadata, + &receivingCurrencyCode, + &receivingCurrencyDecimals, + &conversionRate, + &fee, + &[]string{"abcdef12345"}, + nil, + &utxoCallback, + &payeeData, + &serializedPrivateKey, + &payeeIdentifier, + nil, + nil, ) require.NoError(t, err) + require.Equal(t, payreqResponse.PaymentInfo.CurrencyCode, *payreq.ReceivingCurrencyCode) + require.Equal(t, payreqResponse.PaymentInfo.Multiplier, conversionRate) + require.Equal(t, payreqResponse.PaymentInfo.Decimals, receivingCurrencyDecimals) + require.Equal(t, payreqResponse.PaymentInfo.ExchangeFeesMillisatoshi, fee) + compliance, err := payreqResponse.PayeeData.Compliance() + require.NoError(t, err) + require.Equal(t, compliance.Utxos, []string{"abcdef12345"}) payreqResponseJson, err := json.Marshal(payreqResponse) require.NoError(t, err) @@ -309,12 +610,154 @@ func TestPayReqResponseAndParsing(t *testing.T) { require.Equal(t, payreqResponse, parsedResponse) } -func createLnurlpRequest(t *testing.T, signingPrivateKey []byte) *uma.LnurlpRequest { +func TestSignAndVerifyPostTransactionCallback(t *testing.T) { + signingPrivateKey, err := secp256k1.GeneratePrivateKey() + require.NoError(t, err) + callback, err := uma.GetPostTransactionCallback( + []umaprotocol.UtxoWithAmount{{Utxo: "abcdef12345", Amount: 1000}}, + "my-vasp.com", + signingPrivateKey.Serialize(), + ) + require.NoError(t, err) + callbackJson, err := json.Marshal(callback) + require.NoError(t, err) + parsedCallback, err := uma.ParsePostTransactionCallback(callbackJson) + require.NoError(t, err) + err = uma.VerifyPostTransactionCallbackSignature(parsedCallback, getPubKeyResponse(signingPrivateKey), getNonceCache()) + require.NoError(t, err) +} + +func TestParsePayReqFromQueryParamsNoOptionalFields(t *testing.T) { + amount := "1000" + params := url.Values{ + "amount": {amount}, + } + payreq, err := umaprotocol.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 := umaprotocol.PayerData{ + "identifier": "$bob@vasp.com", + } + encodedPayerData, err := json.Marshal(payerData) + require.NoError(t, err) + payeeData := umaprotocol.CounterPartyDataOptions{ + "identifier": umaprotocol.CounterPartyDataOption{ + Mandatory: true, + }, + "name": umaprotocol.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 := umaprotocol.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 := umaprotocol.PayerData{ + "identifier": "$bob@vasp.com", + } + encodedPayerData, err := json.Marshal(payerData) + require.NoError(t, err) + payeeData := umaprotocol.CounterPartyDataOptions{ + "identifier": umaprotocol.CounterPartyDataOption{ + Mandatory: true, + }, + "name": umaprotocol.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 := umaprotocol.ParsePayRequestFromQueryParams(params) + require.NoError(t, err) + encodedParams, err := payreq.EncodeAsUrlParams() + require.NoError(t, err) + require.Equal(t, params, *encodedParams) + payreqReparsed, err := umaprotocol.ParsePayRequestFromQueryParams(*encodedParams) + require.NoError(t, err) + require.Equal(t, payreq, payreqReparsed) +} + +func TestCertificateUtils(t *testing.T) { + pemCert := `-----BEGIN CERTIFICATE----- +MIIB1zCCAXygAwIBAgIUGN3ihBj1RnKoeTM/auDFnNoThR4wCgYIKoZIzj0EAwIw +QjELMAkGA1UEBhMCVVMxEzARBgNVBAgMCmNhbGlmb3JuaWExDjAMBgNVBAcMBWxv +cyBhMQ4wDAYDVQQKDAVsaWdodDAeFw0yNDAzMDUyMTAzMTJaFw0yNDAzMTkyMTAz +MTJaMEIxCzAJBgNVBAYTAlVTMRMwEQYDVQQIDApjYWxpZm9ybmlhMQ4wDAYDVQQH +DAVsb3MgYTEOMAwGA1UECgwFbGlnaHQwVjAQBgcqhkjOPQIBBgUrgQQACgNCAARB +nFRn6lY/ABD9YU+F6IWsmcIbjo1BYkEXX91e/SJE/pB+Lm+j3WYxsbF80oeY2o2I +KjTEd21EzECQeBx6reobo1MwUTAdBgNVHQ4EFgQUU87LnQdiP6XIE6LoKU1PZnbt +bMwwHwYDVR0jBBgwFoAUU87LnQdiP6XIE6LoKU1PZnbtbMwwDwYDVR0TAQH/BAUw +AwEB/zAKBggqhkjOPQQDAgNJADBGAiEAvsrvoeo3rbgZdTHxEUIgP0ArLyiO34oz +NlwL4gk5GpgCIQCvRx4PAyXNV9T6RRE+3wFlqwluOc/pPOjgdRw/wpoNPQ== +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIICdjCCAV6gAwIBAgIUAekCcU1Qhjo2Y6L2Down9BLdfdUwDQYJKoZIhvcNAQEL +BQAwNDELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAmNhMQwwCgYDVQQHDANsb3MxCjAI +BgNVBAoMAWEwHhcNMjQwMzA4MDEwNTU3WhcNMjUwMzA4MDEwNTU3WjBAMQswCQYD +VQQGEwJVUzELMAkGA1UECAwCY2ExDDAKBgNVBAcMA2xvczEKMAgGA1UECgwBYTEK +MAgGA1UECwwBYTBWMBAGByqGSM49AgEGBSuBBAAKA0IABJ11ZAQKylgIzZmuI5NE ++DyZ9BUDZhxUPSxTxl+s1am+Lxzr9D7wlwOiiqCYHFWpL6lkCmJcCC06P3RyzXIT +KmyjQjBAMB0GA1UdDgQWBBRXgW6xGB3+mTSSUKlhSiu3LS+TKTAfBgNVHSMEGDAW +gBTFmyv7+YDpK0WAOHJYAzjynmWsMDANBgkqhkiG9w0BAQsFAAOCAQEAFVAA3wo+ +Hi/k+OWO/1CFqIRV/0cA8F05sBMiKVA11xB6I1y54aUV4R0jN76fOiN1jnZqTRnM +G8rZUfQgE/LPVbb1ERHQfd8yaeI+TerKdPkMseu/jnvI+dDJfQdsY7iaa7NPO0dm +t8Nz75cYW8kYuDaq0Hb6uGsywf9LGO/VjrDhyiRxmZ1Oq4JxQmLuh5SDcPfqHTR3 +VbMC1b7eVXaA9O2qYS36zv8cCUSUl5sOSwM6moaFN+xLtVNJ6ZhKPNS2Gd8znhzZ +AQZcDDpXBO6ORNbhVk5A3X6eQX4Ek1HBTa3pcSUQomYAA9TIuVzL6DSot5GWS8Ek +usLY8crt6ys3KQ== +-----END CERTIFICATE----- +` + pubkey, err := utils.ExtractPubkeyFromPemCertificateChain(&pemCert) + require.NoError(t, err) + + publicKeyBytes := pubkey.SerializeUncompressed() + expectedPublicKey, err := hex.DecodeString("04419c5467ea563f0010fd614f85e885ac99c21b8e8d416241175fdd5efd2244fe907e2e6fa3dd6631b1b17cd28798da8d882a34c4776d44cc4090781c7aadea1b") + require.NoError(t, err) + require.Equal(t, publicKeyBytes, expectedPublicKey) + + hexDerCerts, err := utils.ConvertPemCertificateChainToHexEncodedDer(&pemCert) + require.NoError(t, err) + require.Len(t, hexDerCerts, 2) + newPemCert, err := utils.ConvertHexEncodedDerToPemCertChain(&hexDerCerts) + require.NoError(t, err) + require.Equal(t, *newPemCert, pemCert) +} + +func createLnurlpRequest(t *testing.T, signingPrivateKey []byte) umaprotocol.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 { @@ -335,3 +778,14 @@ func createMetadataForBob() (string, error) { return string(jsonMetadata), nil } + +func getPubKeyResponse(privateKey *secp256k1.PrivateKey) umaprotocol.PubKeyResponse { + pubKey := hex.EncodeToString(privateKey.PubKey().SerializeUncompressed()) + return umaprotocol.PubKeyResponse{ + SigningCertChain: nil, + EncryptionCertChain: nil, + SigningPubKeyHex: &pubKey, + EncryptionPubKeyHex: &pubKey, + ExpirationTimestamp: nil, + } +} diff --git a/uma/uma.go b/uma/uma.go index b19558e..3628a50 100644 --- a/uma/uma.go +++ b/uma/uma.go @@ -9,6 +9,8 @@ import ( "github.com/decred/dcrd/dcrec/secp256k1/v4" "github.com/decred/dcrd/dcrec/secp256k1/v4/ecdsa" eciesgo "github.com/ecies/go/v2" + "github.com/uma-universal-money-address/uma-go-sdk/uma/protocol" + "github.com/uma-universal-money-address/uma-go-sdk/uma/utils" "io" "math" "math/big" @@ -31,14 +33,14 @@ import ( // // vaspDomain: the domain of the VASP. // cache: the PublicKeyCache cache to use. You can use the InMemoryPublicKeyCache struct, or implement your own persistent cache with any storage type. -func FetchPublicKeyForVasp(vaspDomain string, cache PublicKeyCache) (*PubKeyResponse, error) { +func FetchPublicKeyForVasp(vaspDomain string, cache PublicKeyCache) (*protocol.PubKeyResponse, error) { publicKey := cache.FetchPublicKeyForVasp(vaspDomain) if publicKey != nil { return publicKey, nil } scheme := "https://" - if IsDomainLocalhost(vaspDomain) { + if utils.IsDomainLocalhost(vaspDomain) { scheme = "http://" } resp, err := http.Get(scheme + vaspDomain + "/.well-known/lnurlpubkey") @@ -62,7 +64,7 @@ func FetchPublicKeyForVasp(vaspDomain string, cache PublicKeyCache) (*PubKeyResp return nil, err } - var pubKeyResponse PubKeyResponse + var pubKeyResponse protocol.PubKeyResponse err = json.Unmarshal(responseBodyBytes, &pubKeyResponse) if err != nil { return nil, err @@ -72,6 +74,37 @@ func FetchPublicKeyForVasp(vaspDomain string, cache PublicKeyCache) (*PubKeyResp return &pubKeyResponse, nil } +// GetPubKeyResponse Creates a public key response to be shared with the counterparty VASP. +// +// Args: +// +// signingCertChainPem: The PEM-encoded certificate chain used to verify signatures from a VASP. +// encryptionCertChainPem: The PEM-encoded certificate chain used to encrypt TR info sent to a VASP. +// expirationTimestamp: Seconds since epoch at which these pub keys must be refreshed. It can be safely cached until this expiration (or forever if null). +func GetPubKeyResponse( + signingCertChainPem string, + encryptionCertChainPem string, + expirationTimestamp *int64, +) (*protocol.PubKeyResponse, error) { + signingPubKey, err := utils.ExtractPubkeyFromPemCertificateChain(&signingCertChainPem) + if err != nil { + return nil, err + } + encryptionPubKey, err := utils.ExtractPubkeyFromPemCertificateChain(&encryptionCertChainPem) + if err != nil { + return nil, err + } + signingPubKeyHex := hex.EncodeToString(signingPubKey.SerializeUncompressed()) + encryptionPubKeyHex := hex.EncodeToString(encryptionPubKey.SerializeUncompressed()) + return &protocol.PubKeyResponse{ + SigningCertChain: &signingCertChainPem, + EncryptionCertChain: &encryptionCertChainPem, + SigningPubKeyHex: &signingPubKeyHex, + EncryptionPubKeyHex: &encryptionPubKeyHex, + ExpirationTimestamp: expirationTimestamp, + }, nil +} + func GenerateNonce() (*string, error) { randomBigInt, err := rand.Int(rand.Reader, big.NewInt(0xFFFFFFFF)) if err != nil { @@ -103,16 +136,28 @@ func signPayload(payload []byte, privateKeyBytes []byte) (*string, error) { // Args: // // query: the signed query to verify. -// otherVaspPubKey: the bytes of the signing public key of the VASP making this request. +// otherVaspPubKeyResponse: the PubKeyResponse of the VASP making this request. // nonceCache: the NonceCache cache to use to prevent replay attacks. -func VerifyPayReqSignature(query *PayRequest, otherVaspPubKey []byte, nonceCache NonceCache) error { - err := nonceCache.CheckAndSaveNonce( - query.PayerData.Compliance.SignatureNonce, - time.Unix(query.PayerData.Compliance.SignatureTimestamp, 0)) +func VerifyPayReqSignature(query *protocol.PayRequest, otherVaspPubKeyResponse protocol.PubKeyResponse, nonceCache NonceCache) error { + complianceData, err := query.PayerData.Compliance() if err != nil { return err } - return verifySignature(query.signablePayload(), query.PayerData.Compliance.Signature, otherVaspPubKey) + if complianceData == nil { + return errors.New("missing compliance data") + } + err = nonceCache.CheckAndSaveNonce( + complianceData.SignatureNonce, + time.Unix(complianceData.SignatureTimestamp, 0), + ) + if err != nil { + return err + } + signablePayload, err := query.SignablePayload() + if err != nil { + return err + } + return verifySignature(signablePayload, complianceData.Signature, otherVaspPubKeyResponse) } // verifySignature Verifies the signature of the uma request. @@ -121,8 +166,8 @@ func VerifyPayReqSignature(query *PayRequest, otherVaspPubKey []byte, nonceCache // // payload: the payload that was signed. // signature: the hex-encoded signature. -// otherVaspPubKey: the bytes of the signing public key of the VASP who signed the payload. -func verifySignature(payload []byte, signature string, otherVaspPubKey []byte) error { +// otherVaspPubKeyResponse: the PubKeyResponse of the VASP who signed the payload. +func verifySignature(payload []byte, signature string, otherVaspPubKeyResponse protocol.PubKeyResponse) error { decodedSignature, err := hex.DecodeString(signature) if err != nil { return err @@ -131,7 +176,11 @@ func verifySignature(payload []byte, signature string, otherVaspPubKey []byte) e if err != nil { return err } - pubKey, err := secp256k1.ParsePubKey(otherVaspPubKey) + pubKey, err := otherVaspPubKeyResponse.SigningPubKey() + if err != nil { + return err + } + secp256k1Key, err := secp256k1.ParsePubKey(pubKey) if err != nil { return err } @@ -141,7 +190,7 @@ func verifySignature(payload []byte, signature string, otherVaspPubKey []byte) e return err } hashedPayload := sha256.Sum(nil) - verified := parsedSignature.Verify(hashedPayload, pubKey) + verified := parsedSignature.Verify(hashedPayload, secp256k1Key) if !verified { return errors.New("invalid uma signature") @@ -150,7 +199,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: // @@ -174,19 +223,24 @@ func GetSignedLnurlpRequestUrl( if umaVersionOverride != nil { umaVersion = *umaVersionOverride } - unsignedRequest := LnurlpRequest{ + now := time.Now() + unsignedRequest := protocol.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() } @@ -201,26 +255,30 @@ func IsUmaLnurlpQuery(url url.URL) bool { if errors.As(err, &unsupportedVersionError) { return true } - return err == nil && query != nil + return err == nil && query != nil && query.IsUmaRequest() } // ParseLnurlpRequest Parse Parses the message into an LnurlpRequest object. // Args: // // url: the full URL of the uma request. -func ParseLnurlpRequest(url url.URL) (*LnurlpRequest, error) { +func ParseLnurlpRequest(url url.URL) (*protocol.LnurlpRequest, error) { query := url.Query() 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 umaVersion != "" && !IsVersionSupported(umaVersion) { return nil, UnsupportedVersionError{ @@ -239,14 +297,21 @@ func ParseLnurlpRequest(url url.URL) (*LnurlpRequest, error) { } receiverAddress := pathParts[3] + "@" + url.Host - return &LnurlpRequest{ - VaspDomain: vaspDomain, - UmaVersion: umaVersion, - Signature: signature, + nilIfEmpty := func(s string) *string { + if s == "" { + return nil + } + return &s + } + + return &protocol.LnurlpRequest{ 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 } @@ -255,57 +320,106 @@ func ParseLnurlpRequest(url url.URL) (*LnurlpRequest, error) { // Args: // // query: the signed query to verify. -// otherVaspSigningPubKey: the public key of the VASP making this request in bytes. +// otherVaspPubKeyResponse: the PubKeyResponse 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 protocol.UmaLnurlpRequest, otherVaspPubKeyResponse protocol.PubKeyResponse, 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, otherVaspPubKeyResponse) } func GetLnurlpResponse( - request *LnurlpRequest, - privateKeyBytes []byte, - requiresTravelRuleInfo bool, + request protocol.LnurlpRequest, callback string, encodedMetadata string, minSendableSats int64, maxSendableSats int64, - payerDataOptions PayerDataOptions, - currencyOptions []Currency, - receiverKycStatus KycStatus, -) (*LnurlpResponse, error) { - umaVersion, err := SelectLowerVersion(request.UmaVersion, UmaProtocolVersion) - if err != nil { - return nil, err + privateKeyBytes *[]byte, + requiresTravelRuleInfo *bool, + payerDataOptions *protocol.CounterPartyDataOptions, + currencyOptions *[]protocol.Currency, + receiverKycStatus *protocol.KycStatus, + commentCharsAllowed *int, + nostrPubkey *string, +) (*protocol.LnurlpResponse, error) { + isUmaRequest := request.IsUmaRequest() + var complianceResponse *protocol.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 + } + + // UMA always requires compliance and identifier fields: + if payerDataOptions == nil { + payerDataOptions = &protocol.CounterPartyDataOptions{} + } + (*payerDataOptions)[protocol.CounterPartyDataFieldCompliance.String()] = protocol.CounterPartyDataOption{Mandatory: true} + (*payerDataOptions)[protocol.CounterPartyDataFieldIdentifier.String()] = protocol.CounterPartyDataOption{Mandatory: true} } - complianceResponse, err := getSignedLnurlpComplianceResponse(request, privateKeyBytes, requiresTravelRuleInfo, receiverKycStatus) - if err != nil { - return nil, err + // Ensure currencies are correctly serialized: + if umaVersion != nil { + umaVersionParsed, err := ParseVersion(*umaVersion) + if err != nil && umaVersionParsed != nil && umaVersionParsed.Major == 0 { + for i := range *currencyOptions { + (*currencyOptions)[i].UmaMajorVersion = 0 + } + } } - return &LnurlpResponse{ - Tag: "payRequest", - Callback: callback, - MinSendable: minSendableSats * 1000, - MaxSendable: maxSendableSats * 1000, - EncodedMetadata: encodedMetadata, - Currencies: currencyOptions, - RequiredPayerData: payerDataOptions, - Compliance: *complianceResponse, - UmaVersion: *umaVersion, + var allowsNostr *bool = nil + if nostrPubkey != nil { + *allowsNostr = true + } + return &protocol.LnurlpResponse{ + Tag: "protocol.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 protocol.LnurlpRequest, privateKeyBytes []byte, isSubjectToTravelRule bool, - receiverKycStatus KycStatus, -) (*LnurlComplianceResponse, error) { + receiverKycStatus protocol.KycStatus, +) (*protocol.LnurlComplianceResponse, error) { timestamp := time.Now().Unix() nonce, err := GenerateNonce() if err != nil { @@ -316,7 +430,7 @@ func getSignedLnurlpComplianceResponse( if err != nil { return nil, err } - return &LnurlComplianceResponse{ + return &protocol.LnurlComplianceResponse{ KycStatus: receiverKycStatus, Signature: *signature, Nonce: *nonce, @@ -331,18 +445,18 @@ func getSignedLnurlpComplianceResponse( // Args: // // response: the signed response to verify. -// otherVaspSigningPubKey: the public key of the VASP making this request in bytes. +// otherVaspPubKeyResponse: the PubKeyResponse 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 protocol.UmaLnurlpResponse, otherVaspPubKeyResponse protocol.PubKeyResponse, nonceCache NonceCache) error { err := nonceCache.CheckAndSaveNonce(response.Compliance.Nonce, time.Unix(response.Compliance.Timestamp, 0)) if err != nil { return err } - return verifySignature(response.signablePayload(), response.Compliance.Signature, otherVaspSigningPubKey) + return verifySignature(response.SignablePayload(), response.Compliance.Signature, otherVaspPubKeyResponse) } -func ParseLnurlpResponse(bytes []byte) (*LnurlpResponse, error) { - var response LnurlpResponse +func ParseLnurlpResponse(bytes []byte) (*protocol.LnurlpResponse, error) { + var response protocol.LnurlpResponse err := json.Unmarshal(bytes, &response) if err != nil { return nil, err @@ -359,39 +473,54 @@ 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 protocol.PayRequest directly. // // Args: // -// 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. -// currencyCode: the code of the currency that the receiver will receive for this payment. -// 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. -// 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. -func GetPayRequest( +// 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). +// payerIdentifier: the identifier of the sender. For example, $alice@vasp1.com +// umaMajorVersion: the major version of UMA used for this request. If non-UMA, this version is still relevant +// for which LUD-21 spec to follow. For the older LUD-21 spec, this should be 0. For the newer LUD-21 spec, +// this should be 1. +// 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. +// 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. +// 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, - currencyCode string, - amount int64, + receivingCurrencyCode string, + isAmountInReceivingCurrency bool, payerIdentifier string, + umaMajorVersion int, payerName *string, payerEmail *string, trInfo *string, - trInfoFormat *TravelRuleFormat, - payerKycStatus KycStatus, + trInfoFormat *protocol.TravelRuleFormat, + payerKycStatus protocol.KycStatus, payerUtxos *[]string, payerNodePubKey *string, utxoCallback string, -) (*PayRequest, error) { + requestedPayeeData *protocol.CounterPartyDataOptions, + comment *string, +) (*protocol.PayRequest, error) { complianceData, err := getSignedCompliancePayerData( receiverEncryptionPubKey, sendingVaspPrivateKey, @@ -406,16 +535,36 @@ func GetPayRequest( if err != nil { return nil, err } + if requestedPayeeData == nil { + requestedPayeeData = &protocol.CounterPartyDataOptions{} + } + // UMA always requires compliance and identifier fields: + (*requestedPayeeData)[protocol.CounterPartyDataFieldCompliance.String()] = protocol.CounterPartyDataOption{Mandatory: true} + (*requestedPayeeData)[protocol.CounterPartyDataFieldIdentifier.String()] = protocol.CounterPartyDataOption{Mandatory: true} - return &PayRequest{ - CurrencyCode: currencyCode, - Amount: amount, - PayerData: PayerData{ - Name: payerName, - Email: payerEmail, - Identifier: payerIdentifier, - Compliance: complianceData, + sendingAmountCurrencyCode := &receivingCurrencyCode + if !isAmountInReceivingCurrency { + sendingAmountCurrencyCode = nil + } + + complianceDataMap, err := complianceData.AsMap() + if err != nil { + return nil, err + } + + return &protocol.PayRequest{ + SendingAmountCurrencyCode: sendingAmountCurrencyCode, + ReceivingCurrencyCode: &receivingCurrencyCode, + Amount: amount, + PayerData: &protocol.PayerData{ + protocol.CounterPartyDataFieldName.String(): payerName, + protocol.CounterPartyDataFieldEmail.String(): payerEmail, + protocol.CounterPartyDataFieldIdentifier.String(): payerIdentifier, + protocol.CounterPartyDataFieldCompliance.String(): complianceDataMap, }, + RequestedPayeeData: requestedPayeeData, + Comment: comment, + UmaMajorVersion: umaMajorVersion, }, nil } @@ -424,12 +573,12 @@ func getSignedCompliancePayerData( sendingVaspPrivateKeyBytes []byte, payerIdentifier string, trInfo *string, - trInfoFormat *TravelRuleFormat, - payerKycStatus KycStatus, + trInfoFormat *protocol.TravelRuleFormat, + payerKycStatus protocol.KycStatus, payerUtxos *[]string, payerNodePubKey *string, utxoCallback string, -) (*CompliancePayerData, error) { +) (*protocol.CompliancePayerData, error) { timestamp := time.Now().Unix() nonce, err := GenerateNonce() if err != nil { @@ -448,7 +597,7 @@ func getSignedCompliancePayerData( return nil, err } - return &CompliancePayerData{ + return &protocol.CompliancePayerData{ EncryptedTravelRuleInfo: encryptedTrInfo, TravelRuleFormat: trInfoFormat, KycStatus: payerKycStatus, @@ -476,8 +625,8 @@ func encryptTrInfo(trInfo string, receiverEncryptionPubKey []byte) (*string, err return &encryptedTrInfoHex, nil } -func ParsePayRequest(bytes []byte) (*PayRequest, error) { - var response PayRequest +func ParsePayRequest(bytes []byte) (*protocol.PayRequest, error) { + var response protocol.PayRequest err := json.Unmarshal(bytes, &response) if err != nil { return nil, err @@ -485,8 +634,8 @@ func ParsePayRequest(bytes []byte) (*PayRequest, error) { return &response, nil } -type UmaInvoiceCreator interface { - CreateUmaInvoice(amountMsats int64, metadata string) (*string, error) +type InvoiceCreator interface { + CreateInvoice(amountMsats int64, metadata string) (*string, error) } // GetPayReqResponse Creates an uma pay request response with an encoded invoice. @@ -497,9 +646,10 @@ type UmaInvoiceCreator interface { // invoiceCreator: the object that will create the invoice. // metadata: the metadata that will be added to the invoice's metadata hash field. Note that this should not include // the extra payer data. That will be appended automatically. -// currencyCode: the code of the currency that the receiver will receive for this payment. -// currencyDecimals: the number of decimal places in the specified currency. For example, USD has 2 decimal places. -// This should align with the decimals field returned for the chosen currency in the LNURLP response. +// receivingCurrencyCode: the code of the currency that the receiver will receive for this payment. +// receivingCurrencyDecimals: the number of decimal places in the specified currency. For example, USD has 2 +// decimal places. This should align with the decimals field returned for the chosen currency in the LNURLP +// response. // conversionRate: milli-satoshis per the smallest unit of the specified currency. This rate is committed to by the // receiving VASP until the invoice expires. // receiverFeesMillisats: the fees charged (in millisats) by the receiving VASP to convert to the target currency. @@ -509,50 +659,343 @@ type UmaInvoiceCreator interface { // this will be used to pre-screen the receiver's UTXOs for compliance purposes. // utxoCallback: the URL that the receiving VASP will call to send UTXOs of the channel that the receiver used to // receive the payment once it completes. +// payeeData: the payee data which was requested by the sender. Can be nil if no payee data was requested or is +// 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 protocol.PayRequest, + invoiceCreator InvoiceCreator, metadata string, - currencyCode string, - currencyDecimals int, - conversionRate float64, - receiverFeesMillisats int64, - receiverChannelUtxos []string, + receivingCurrencyCode *string, + receivingCurrencyDecimals *int, + conversionRate *float64, + receiverFeesMillisats *int64, + receiverChannelUtxos *[]string, receiverNodePubKey *string, - utxoCallback string, -) (*PayReqResponse, error) { - msatsAmount := int64(math.Round(float64(query.Amount)*conversionRate)) + receiverFeesMillisats - encodedPayerData, err := json.Marshal(query.PayerData) + utxoCallback *string, + payeeData *protocol.PayeeData, + receivingVaspPrivateKey *[]byte, + payeeIdentifier *string, + disposable *bool, + successAction *map[string]string, +) (*protocol.PayReqResponse, error) { + if request.SendingAmountCurrencyCode != nil && *request.SendingAmountCurrencyCode != *receivingCurrencyCode { + return nil, errors.New("the sdk only supports sending in either SAT or the receiving currency") + } + err := validatePayReqCurrencyFields(receivingCurrencyCode, receivingCurrencyDecimals, conversionRate, receiverFeesMillisats) if err != nil { return nil, err } - encodedInvoice, err := invoiceCreator.CreateUmaInvoice(msatsAmount, metadata+"{"+string(encodedPayerData)+"}") + conversionRateOrOne := 1.0 + if conversionRate != nil { + conversionRateOrOne = *conversionRate + } + feesOrZero := int64(0) + if receiverFeesMillisats != nil { + feesOrZero = *receiverFeesMillisats + } + msatsAmount := request.Amount + if receivingCurrencyCode != nil && request.SendingAmountCurrencyCode != nil { + msatsAmount = int64(math.Round(float64(request.Amount)*(conversionRateOrOne))) + feesOrZero + } + + 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 } - return &PayReqResponse{ - EncodedInvoice: *encodedInvoice, - Routes: []Route{}, - Compliance: PayReqResponseCompliance{ - Utxos: receiverChannelUtxos, - NodePubKey: receiverNodePubKey, - UtxoCallback: utxoCallback, - }, - PaymentInfo: PayReqResponsePaymentInfo{ - CurrencyCode: currencyCode, - Multiplier: conversionRate, - Decimals: currencyDecimals, - ExchangeFeesMillisatoshi: receiverFeesMillisats, - }, + var complianceData *protocol.CompliancePayeeData + if request.IsUmaRequest() { + err = validateUmaPayReqFields( + receivingCurrencyCode, + receivingCurrencyDecimals, + conversionRate, + receiverFeesMillisats, + receiverChannelUtxos, + receiverNodePubKey, + payeeIdentifier, + receivingVaspPrivateKey, + ) + if err != nil { + return nil, err + } + + 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 + } + if payeeData == nil { + payeeData = &protocol.PayeeData{ + protocol.CounterPartyDataFieldIdentifier.String(): *payeeIdentifier, + } + } + if existingCompliance := (*payeeData)[protocol.CounterPartyDataFieldCompliance.String()]; existingCompliance == nil { + complianceDataAsMap, err := complianceData.AsMap() + if err != nil { + return nil, err + } + (*payeeData)[protocol.CounterPartyDataFieldCompliance.String()] = complianceDataAsMap + } + } + + receivingCurrencyAmount := &request.Amount + if request.SendingAmountCurrencyCode == nil { + receivingCurrencyAmountVal := int64(math.Round(float64(msatsAmount-feesOrZero) / conversionRateOrOne)) + receivingCurrencyAmount = &receivingCurrencyAmountVal + } + if request.UmaMajorVersion == 0 { + receivingCurrencyAmount = nil + } + var paymentInfo *protocol.PayReqResponsePaymentInfo + if receivingCurrencyCode != nil { + paymentInfo = &protocol.PayReqResponsePaymentInfo{ + Amount: receivingCurrencyAmount, + CurrencyCode: *receivingCurrencyCode, + Multiplier: *conversionRate, + Decimals: *receivingCurrencyDecimals, + ExchangeFeesMillisatoshi: *receiverFeesMillisats, + } + } + return &protocol.PayReqResponse{ + EncodedInvoice: *encodedInvoice, + Routes: []protocol.Route{}, + PaymentInfo: paymentInfo, + PayeeData: payeeData, + Disposable: disposable, + SuccessAction: successAction, + UmaMajorVersion: request.UmaMajorVersion, }, 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, +) (*protocol.CompliancePayeeData, error) { + timestamp := time.Now().Unix() + nonce, err := GenerateNonce() + if err != nil { + return nil, err + } + complianceData := protocol.CompliancePayeeData{ + Utxos: receiverChannelUtxos, + NodePubKey: receiverNodePubKey, + UtxoCallback: utxoCallback, + Signature: nil, + SignatureNonce: nonce, + SignatureTimestamp: ×tamp, + } + payloadString, err := complianceData.SignablePayload(payerIdentifier, payeeIdentifier) + if err != nil { + return nil, err + } + signature, err := signPayload([]byte(payloadString), receivingVaspPrivateKeyBytes) + if err != nil { + return nil, err + } + complianceData.Signature = signature + return &complianceData, nil +} + // ParsePayReqResponse Parses the uma pay request response from a raw response body. -func ParsePayReqResponse(bytes []byte) (*PayReqResponse, error) { - var response PayReqResponse - err := json.Unmarshal(bytes, &response) +func ParsePayReqResponse(bytes []byte) (*protocol.PayReqResponse, error) { + var response protocol.PayReqResponse + err := response.UnmarshalJSON(bytes) if err != nil { return nil, err } return &response, nil } + +// VerifyPayReqResponseSignature Verifies the signature on an uma pay request response based on the public key of the +// VASP making the request. +// +// Args: +// +// response: the signed response to verify. +// otherVaspPubKeyResponse: the PubKeyResponse of the VASP making this request. +// nonceCache: the NonceCache cache to use to prevent replay attacks. +// payerIdentifier: the identifier of the sender. For example, $alice@vasp1.com +// payeeIdentifier: the identifier of the receiver. For example, $bob@vasp2.com +func VerifyPayReqResponseSignature( + response *protocol.PayReqResponse, + otherVaspPubKeyResponse protocol.PubKeyResponse, + nonceCache NonceCache, + payerIdentifier string, + payeeIdentifier string, +) error { + complianceData, err := response.PayeeData.Compliance() + if err != nil { + return err + } + if complianceData == nil { + return errors.New("missing compliance data") + } + if response.UmaMajorVersion == 0 { + return errors.New("signatures were added to payreq responses in UMA v1. This response is from an UMA v0 receiving VASP") + } + err = nonceCache.CheckAndSaveNonce( + *complianceData.SignatureNonce, + time.Unix(*complianceData.SignatureTimestamp, 0), + ) + if err != nil { + return err + } + signablePayload, err := complianceData.SignablePayload(payerIdentifier, payeeIdentifier) + if err != nil { + return err + } + return verifySignature(signablePayload, *complianceData.Signature, otherVaspPubKeyResponse) +} + +// GetPostTransactionCallback Creates a signed post transaction callback. +// +// Args: +// +// utxos: UTXOs of the channels of the VASP initiating the callback. +// vaspDomain: the domain of the VASP initiating the callback. +// signingPrivateKey: the private key of the VASP initiating the callback. This will be used to sign the request. +func GetPostTransactionCallback( + utxos []protocol.UtxoWithAmount, + vaspDomain string, + signingPrivateKey []byte, +) (*protocol.PostTransactionCallback, error) { + nonce, err := GenerateNonce() + if err != nil { + return nil, err + } + timestamp := time.Now().Unix() + unsignedCallback := protocol.PostTransactionCallback{ + Utxos: utxos, + VaspDomain: &vaspDomain, + Timestamp: ×tamp, + Nonce: nonce, + } + signablePayload, err := unsignedCallback.SignablePayload() + if err != nil { + return nil, err + } + signature, err := signPayload(*signablePayload, signingPrivateKey) + if err != nil { + return nil, err + } + unsignedCallback.Signature = signature + return &unsignedCallback, nil +} + +func ParsePostTransactionCallback(bytes []byte) (*protocol.PostTransactionCallback, error) { + var callback protocol.PostTransactionCallback + err := json.Unmarshal(bytes, &callback) + if err != nil { + return nil, err + } + return &callback, nil +} + +// VerifyPostTransactionCallbackSignature Verifies the signature on a post transaction callback based on the +// public key of the counterparty VASP. +// +// Args: +// +// callback: the signed callback to verify. +// otherVaspPubKeyResponse: the PubKeyResponse of the VASP making this request. +// nonceCache: the NonceCache cache to use to prevent replay attacks. +func VerifyPostTransactionCallbackSignature( + callback *protocol.PostTransactionCallback, + otherVaspPubKeyResponse protocol.PubKeyResponse, + nonceCache NonceCache, +) error { + if callback.Signature == nil || callback.Nonce == nil || callback.Timestamp == nil { + return errors.New("missing signature. Is this a UMA v0 callback? UMA v0 does not require signatures.") + } + err := nonceCache.CheckAndSaveNonce(*callback.Nonce, time.Unix(*callback.Timestamp, 0)) + if err != nil { + return err + } + signablePayload, err := callback.SignablePayload() + if err != nil { + return err + } + return verifySignature(*signablePayload, *callback.Signature, otherVaspPubKeyResponse) +} diff --git a/uma/utils/cert_utils.go b/uma/utils/cert_utils.go new file mode 100644 index 0000000..1311bd5 --- /dev/null +++ b/uma/utils/cert_utils.go @@ -0,0 +1,112 @@ +package utils + +import ( + "crypto/x509/pkix" + "encoding/asn1" + "encoding/hex" + "encoding/pem" + "errors" + "github.com/decred/dcrd/dcrec/secp256k1/v4" + "math/big" + "time" +) + +func ConvertPemCertificateChainToHexEncodedDer(certChain *string) ([]string, error) { + if certChain == nil { + return []string{}, nil + } + asn1Certs, err := getAsn1DataFromPemChain(certChain) + if err != nil { + return nil, err + } + var v []string + for _, block := range *asn1Certs { + v = append(v, hex.EncodeToString(block)) + } + return v, nil +} + +func ConvertHexEncodedDerToPemCertChain(hexDerCerts *[]string) (*string, error) { + if hexDerCerts == nil || len(*hexDerCerts) == 0 { + return nil, nil + } + var pemCertChain string + for _, hexDerCert := range *hexDerCerts { + derCert, err := hex.DecodeString(hexDerCert) + if err != nil { + return nil, err + } + block := &pem.Block{ + Type: "CERTIFICATE", + Bytes: derCert, + } + pemCertChain = pemCertChain + string(pem.EncodeToMemory(block)) + } + return &pemCertChain, nil +} + +func ExtractPubkeyFromPemCertificateChain(certChain *string) (*secp256k1.PublicKey, error) { + asn1Certs, err := getAsn1DataFromPemChain(certChain) + if err != nil { + return nil, err + } + if len(*asn1Certs) == 0 { + return nil, errors.New("empty certificate chain") + } + cert := new(certificate) + _, err = asn1.Unmarshal((*asn1Certs)[0], cert) + if err != nil { + return nil, err + } + return parseToSecp256k1PublicKey(&cert.TBSCertificate.PublicKey) +} + +func getAsn1DataFromPemChain(certChain *string) (*[][]byte, error) { + pemData := []byte(*certChain) + var v [][]byte + for len(pemData) > 0 { + var block *pem.Block + block, pemData = pem.Decode(pemData) + if block == nil { + return nil, errors.New("failed to decode PEM block") + } + v = append(v, block.Bytes) + } + return &v, nil +} + +func parseToSecp256k1PublicKey(keyData *publicKeyInfo) (*secp256k1.PublicKey, error) { + asn1Data := keyData.PublicKey.RightAlign() + return secp256k1.ParsePubKey(asn1Data) +} + +type certificate struct { + Raw asn1.RawContent + TBSCertificate tbsCertificate + SignatureAlgorithm pkix.AlgorithmIdentifier + SignatureValue asn1.BitString +} + +type tbsCertificate struct { + Raw asn1.RawContent + Version int `asn1:"optional,explicit,default:0,tag:0"` + SerialNumber *big.Int + SignatureAlgorithm pkix.AlgorithmIdentifier + Issuer asn1.RawValue + Validity validity + Subject asn1.RawValue + PublicKey publicKeyInfo + UniqueId asn1.BitString `asn1:"optional,tag:1"` + SubjectUniqueId asn1.BitString `asn1:"optional,tag:2"` + Extensions []pkix.Extension `asn1:"optional,explicit,tag:3"` +} + +type publicKeyInfo struct { + Raw asn1.RawContent + Algorithm pkix.AlgorithmIdentifier + PublicKey asn1.BitString +} + +type validity struct { + NotBefore, NotAfter time.Time +} diff --git a/uma/url_utils.go b/uma/utils/url_utils.go similarity index 95% rename from uma/url_utils.go rename to uma/utils/url_utils.go index 3f91b0e..3c10f03 100644 --- a/uma/url_utils.go +++ b/uma/utils/url_utils.go @@ -1,4 +1,4 @@ -package uma +package utils import "strings" diff --git a/uma/version.go b/uma/version.go index 6011ae4..3098246 100644 --- a/uma/version.go +++ b/uma/version.go @@ -3,12 +3,12 @@ package uma import ( "encoding/json" "fmt" - "strconv" - "strings" ) -const MAJOR_VERSION = 0 -const MINOR_VERSION = 3 +const MAJOR_VERSION = 1 +const MINOR_VERSION = 0 + +var backcompatVersions = []string{"0.3"} var UmaProtocolVersion = fmt.Sprintf("%d.%d", MAJOR_VERSION, MINOR_VERSION) @@ -22,23 +22,12 @@ func (e UnsupportedVersionError) Error() string { } func GetSupportedMajorVersionsFromErrorResponseBody(errorResponseBody []byte) ([]int, error) { - responseJson := make(map[string]string) + var responseJson UnsupportedVersionError err := json.Unmarshal(errorResponseBody, &responseJson) if err != nil { return nil, err } - - vasp2SupportedMajorVersions := responseJson["supportedMajorVersions"] - vasp2SupportedMajorVersionsList := strings.Split(vasp2SupportedMajorVersions, ",") - vasp2SupportedMajorVersionsIntList := make([]int, len(vasp2SupportedMajorVersionsList)) - for i, version := range vasp2SupportedMajorVersionsList { - versionInt, err := strconv.Atoi(version) - if err != nil { - return nil, err - } - vasp2SupportedMajorVersionsIntList[i] = versionInt - } - return vasp2SupportedMajorVersionsIntList, nil + return responseJson.SupportedMajorVersions, nil } func getSupportedMajorVersionsMap() map[int]struct{} { @@ -55,17 +44,35 @@ func getSupportedMajorVersionsMap() map[int]struct{} { func GetSupportedMajorVersions() []int { // NOTE: In the future, we may want to support multiple major versions in the same SDK, but for now, this keeps // things simple. - return []int{MAJOR_VERSION} + majorVersions := []int{MAJOR_VERSION} + for _, version := range backcompatVersions { + parsedVersion, err := ParseVersion(version) + if err != nil { + continue + } + majorVersions = append(majorVersions, parsedVersion.Major) + } + + return majorVersions } func GetHighestSupportedVersionForMajorVersion(majorVersion int) *ParsedVersion { // Note that this also only supports a single major version for now. If we support more than one major version in // the future, we'll need to change this. - if majorVersion != MAJOR_VERSION { - return nil + if majorVersion == MAJOR_VERSION { + parsedVersion, _ := ParseVersion(UmaProtocolVersion) + return parsedVersion + } + for _, version := range backcompatVersions { + parsedVersion, err := ParseVersion(version) + if err != nil { + continue + } + if parsedVersion.Major == majorVersion { + return parsedVersion + } } - parsedVersion, _ := ParseVersion(UmaProtocolVersion) - return parsedVersion + return nil } func SelectHighestSupportedVersion(otherVaspSupportedMajorVersions []int) *string {