From 6b1a07c7cd68b492d7fa7e590a9faeb7b269ba2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Lewandowski?= <35259896+pawellewandowski98@users.noreply.github.com> Date: Fri, 24 May 2024 13:21:15 +0200 Subject: [PATCH] feat(SPV-789): extend PIKE capability (#91) --- brfc_definintions.go | 6 +- examples/server/run_server/demo_interface.go | 9 +++ examples/server/run_server/run_server.go | 3 +- pike.go | 26 +++++++ server/capabilities.go | 52 ++++++++++++-- server/config.go | 22 ++++-- server/config_options.go | 17 +++-- server/config_test.go | 71 ++++++++++++++++++-- server/interface.go | 40 ++++++++--- server/mock_test.go | 4 ++ server/p2p_payment_destination.go | 39 ++--------- server/payment.go | 52 ++++++++++++++ server/pike.go | 30 ++++++++- 13 files changed, 303 insertions(+), 68 deletions(-) create mode 100644 server/payment.go diff --git a/brfc_definintions.go b/brfc_definintions.go index 16fab90..ed4b06e 100644 --- a/brfc_definintions.go +++ b/brfc_definintions.go @@ -19,7 +19,11 @@ const ( BRFCVerifyPublicKeyOwner = "a9f510c16bde" // more info: http://bsvalias.org/05-verify-public-key-owner.html BRFCBeefTransaction = "5c55a7fdb7bb" // more info: https://bsv.brc.dev/payments/0070 - BRFCPike = "8c4ed5ef8ace" // more info: TODO BUX-665 + BRFCTemporaryPike = "8c4ed5ef8ace" // Temporary BRFC ID for PIKE + + BRFCPike = "935478af7bf2" + BRFCPikeInvite = "invite" + BRFCPikeOutputs = "outputs" ) // BRFCKnownSpecifications is a running list of all known BRFC specifications diff --git a/examples/server/run_server/demo_interface.go b/examples/server/run_server/demo_interface.go index f6ca6de..2575949 100644 --- a/examples/server/run_server/demo_interface.go +++ b/examples/server/run_server/demo_interface.go @@ -58,3 +58,12 @@ func (d *demoServiceProvider) AddContact( ) error { return nil } + +func (d *demoServiceProvider) CreatePikeDestinationResponse( + ctx context.Context, + alias, domain string, + satoshis uint64, + metaData *server.RequestMetadata, +) (*paymail.PikePaymentOutputsResponse, error) { + return nil, nil +} diff --git a/examples/server/run_server/run_server.go b/examples/server/run_server/run_server.go index 50b1a27..f1657c0 100644 --- a/examples/server/run_server/run_server.go +++ b/examples/server/run_server/run_server.go @@ -22,7 +22,8 @@ func main() { sl := server.PaymailServiceLocator{} sl.RegisterPaymailService(new(demoServiceProvider)) - sl.RegisterPikeService(new(demoServiceProvider)) + sl.RegisterPikeContactService(new(demoServiceProvider)) + sl.RegisterPikePaymentService(new(demoServiceProvider)) // Custom server with lots of customizable goodies config, err := server.NewConfig( diff --git a/pike.go b/pike.go index cef0a56..6e8f996 100644 --- a/pike.go +++ b/pike.go @@ -6,17 +6,43 @@ import ( "fmt" "net/http" "strings" + "time" ) +// PikeContactRequestResponse is PIKE wrapper for StandardResponse type PikeContactRequestResponse struct { StandardResponse } +// PikeContactRequestPayload is a payload used to request a contact type PikeContactRequestPayload struct { FullName string `json:"fullName"` Paymail string `json:"paymail"` } +// PikePaymentOutputsPayload is a payload needed to get payment outputs +// TODO: check if everything is needed after whole PIKE implementation +type PikePaymentOutputsPayload struct { + SenderName string `json:"senderName"` + SenderPaymail string `json:"senderPaymail"` + Amount uint64 `json:"amount"` + Dt time.Time `json:"dt"` + Reference string `json:"reference"` + Signature string `json:"signature"` +} + +// PikePaymentOutputsResponse is a response which contain output templates +type PikePaymentOutputsResponse struct { + Outputs []PikePaymentOutput `json:"outputs"` + Reference string `json:"reference"` +} + +// PikePaymentOutput is a single output template with satoshis +type PikePaymentOutput struct { + Script string `json:"script"` + Satoshis int `json:"satoshis"` +} + func (c *Client) AddContactRequest(url, alias, domain string, request *PikeContactRequestPayload) (*PikeContactRequestResponse, error) { if err := c.validateUrlWithPaymail(url, alias, domain); err != nil { diff --git a/server/capabilities.go b/server/capabilities.go index 674a2b0..4176f59 100644 --- a/server/capabilities.go +++ b/server/capabilities.go @@ -2,11 +2,10 @@ package server import ( "fmt" + "github.com/gin-gonic/gin" "net/http" "strings" - "github.com/gin-gonic/gin" - "github.com/bitcoin-sv/go-paymail" ) @@ -16,6 +15,7 @@ type CallableCapability struct { Handler gin.HandlerFunc } +type NestedCapabilitiesMap map[string]CallableCapabilitiesMap type CallableCapabilitiesMap map[string]CallableCapability type StaticCapabilitiesMap map[string]any @@ -80,16 +80,41 @@ func (c *Configuration) SetBeefCapabilities() { ) } -func (c *Configuration) SetPikeCapabilities() { +func (c *Configuration) SetPikeContactCapabilities() { _addCapabilities(c.callableCapabilities, CallableCapabilitiesMap{ - paymail.BRFCPike: CallableCapability{ + paymail.BRFCTemporaryPike: CallableCapability{ Path: fmt.Sprintf("/pike/%s", PaymailAddressTemplate), Method: http.MethodPost, Handler: c.pikeNewContact, }, }, ) + _addNestedCapabilities(c.nestedCapabilities, + NestedCapabilitiesMap{ + paymail.BRFCPike: CallableCapabilitiesMap{ + paymail.BRFCPikeInvite: CallableCapability{ + Path: fmt.Sprintf("/contact/invite/%s", PaymailAddressTemplate), + Method: http.MethodPost, + Handler: c.pikeNewContact, + }, + }, + }, + ) +} + +func (c *Configuration) SetPikePaymentCapabilities() { + _addNestedCapabilities(c.nestedCapabilities, + NestedCapabilitiesMap{ + paymail.BRFCPike: CallableCapabilitiesMap{ + paymail.BRFCPikeOutputs: CallableCapability{ + Path: fmt.Sprintf("/pike/outputs/%s", PaymailAddressTemplate), + Method: http.MethodPost, + Handler: c.pikeGetPaymentDestinations, + }, + }, + }, + ) } func _addCapabilities[T any](base map[string]T, newCaps map[string]T) { @@ -98,6 +123,15 @@ func _addCapabilities[T any](base map[string]T, newCaps map[string]T) { } } +func _addNestedCapabilities(base NestedCapabilitiesMap, newCaps NestedCapabilitiesMap) { + for key, val := range newCaps { + if _, ok := base[key]; !ok { + base[key] = make(CallableCapabilitiesMap) + } + _addCapabilities(base[key], val) + } +} + // showCapabilities will return the service discovery results for the server // and list all active capabilities of the Paymail server // @@ -142,6 +176,16 @@ func (c *Configuration) EnrichCapabilities(host string) (*paymail.CapabilitiesPa for key, cap := range c.callableCapabilities { payload.Capabilities[key] = serviceUrl + string(cap.Path) } + for key, cap := range c.nestedCapabilities { + payload.Capabilities[key] = make(map[string]interface{}) + for nestedKey, nestedCap := range cap { + nestedObj, ok := payload.Capabilities[key].(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("failed to cast nested capabilities") + } + nestedObj[nestedKey] = serviceUrl + nestedCap.Path + } + } return payload, nil } diff --git a/server/config.go b/server/config.go index 7fa5d24..6044fd2 100644 --- a/server/config.go +++ b/server/config.go @@ -1,12 +1,11 @@ package server import ( + "github.com/rs/zerolog" "slices" "strings" "time" - "github.com/rs/zerolog" - "github.com/bitcoin-sv/go-paymail" ) @@ -23,14 +22,17 @@ type Configuration struct { GenericCapabilitiesEnabled bool `json:"generic_capabilities_enabled"` P2PCapabilitiesEnabled bool `json:"p2p_capabilities_enabled"` BeefCapabilitiesEnabled bool `json:"beef_capabilities_enabled"` - PikeCapabilitiesEnabled bool `json:"pike_capabilities_enabled"` + PikeContactCapabilitiesEnabled bool `json:"pike_contact_capabilities_enabled"` + PikePaymentCapabilitiesEnabled bool `json:"pike_payment_capabilities_enabled"` ServiceName string `json:"service_name"` Timeout time.Duration `json:"timeout"` Logger *zerolog.Logger `json:"logger"` // private actions PaymailServiceProvider - pikeActions PikeServiceProvider + pikeContactActions PikeContactServiceProvider + pikePaymentActions PikePaymentServiceProvider + nestedCapabilities NestedCapabilitiesMap callableCapabilities CallableCapabilitiesMap staticCapabilities StaticCapabilitiesMap } @@ -139,9 +141,15 @@ func NewConfig(serviceProvider *PaymailServiceLocator, opts ...ConfigOps) (*Conf if config.BeefCapabilitiesEnabled { config.SetBeefCapabilities() } - if config.PikeCapabilitiesEnabled { - config.SetPikeCapabilities() - config.pikeActions = serviceProvider.GetPikeService() + + if config.PikeContactCapabilitiesEnabled { + config.SetPikeContactCapabilities() + config.pikeContactActions = serviceProvider.GetPikeContactService() + } + + if config.PikePaymentCapabilitiesEnabled { + config.SetPikePaymentCapabilities() + config.pikePaymentActions = serviceProvider.GetPikePaymentService() } // Validate the configuration diff --git a/server/config_options.go b/server/config_options.go index 9ad2136..ce603c7 100644 --- a/server/config_options.go +++ b/server/config_options.go @@ -28,10 +28,12 @@ func defaultConfigOptions() *Configuration { GenericCapabilitiesEnabled: true, P2PCapabilitiesEnabled: false, BeefCapabilitiesEnabled: false, - PikeCapabilitiesEnabled: false, + PikeContactCapabilitiesEnabled: false, + PikePaymentCapabilitiesEnabled: false, ServiceName: paymail.DefaultServiceName, Timeout: DefaultTimeout, Logger: logging.GetDefaultLogger(), + nestedCapabilities: make(NestedCapabilitiesMap), callableCapabilities: make(CallableCapabilitiesMap), staticCapabilities: make(StaticCapabilitiesMap), } @@ -59,10 +61,17 @@ func WithBeefCapabilities() ConfigOps { } } -// WithPikeCapabilities will load the PIKE capabilities -func WithPikeCapabilities() ConfigOps { +// WithPikeContactCapabilities will load the PIKE capabilities +func WithPikeContactCapabilities() ConfigOps { return func(c *Configuration) { - c.PikeCapabilitiesEnabled = true + c.PikeContactCapabilitiesEnabled = true + } +} + +// WithPikePaymentCapabilities will load the PIKE capabilities +func WithPikePaymentCapabilities() ConfigOps { + return func(c *Configuration) { + c.PikePaymentCapabilitiesEnabled = true } } diff --git a/server/config_test.go b/server/config_test.go index 85f730a..09738b9 100644 --- a/server/config_test.go +++ b/server/config_test.go @@ -13,7 +13,8 @@ import ( func testConfig(t *testing.T, domain string) *Configuration { sl := PaymailServiceLocator{} sl.RegisterPaymailService(new(mockServiceProvider)) - sl.RegisterPikeService(new(mockServiceProvider)) + sl.RegisterPikeContactService(new(mockServiceProvider)) + sl.RegisterPikePaymentService(new(mockServiceProvider)) c, err := NewConfig( &sl, @@ -476,23 +477,60 @@ func TestNewConfig(t *testing.T) { assert.Equal(t, true, c.PaymailDomainsValidationDisabled) }) - t.Run("with pike capabilities", func(t *testing.T) { + t.Run("with pike contact capabilities", func(t *testing.T) { sl := &PaymailServiceLocator{} sl.RegisterPaymailService(new(mockServiceProvider)) - sl.RegisterPikeService(new(mockServiceProvider)) + sl.RegisterPikeContactService(new(mockServiceProvider)) c, err := NewConfig( sl, WithDomain("test.com"), WithP2PCapabilities(), - WithPikeCapabilities(), + WithPikeContactCapabilities(), ) require.NoError(t, err) require.NotNil(t, c) assert.Equal(t, 7, len(c.callableCapabilities)) + assert.Equal(t, 1, len(c.nestedCapabilities)) }) - t.Run("with pike capabilities - pike service is not registered -> should panic", func(t *testing.T) { + t.Run("with pike payment capabilities", func(t *testing.T) { + sl := &PaymailServiceLocator{} + sl.RegisterPaymailService(new(mockServiceProvider)) + sl.RegisterPikePaymentService(new(mockServiceProvider)) + + c, err := NewConfig( + sl, + WithDomain("test.com"), + WithP2PCapabilities(), + WithPikePaymentCapabilities(), + ) + require.NoError(t, err) + require.NotNil(t, c) + assert.Equal(t, 6, len(c.callableCapabilities)) + assert.Equal(t, 1, len(c.nestedCapabilities)) + }) + + t.Run("with both pike capabilities", func(t *testing.T) { + sl := &PaymailServiceLocator{} + sl.RegisterPaymailService(new(mockServiceProvider)) + sl.RegisterPikeContactService(new(mockServiceProvider)) + sl.RegisterPikePaymentService(new(mockServiceProvider)) + + c, err := NewConfig( + sl, + WithDomain("test.com"), + WithP2PCapabilities(), + WithPikeContactCapabilities(), + WithPikePaymentCapabilities(), + ) + require.NoError(t, err) + require.NotNil(t, c) + assert.Equal(t, 7, len(c.callableCapabilities)) + assert.Equal(t, 1, len(c.nestedCapabilities)) + }) + + t.Run("with pike contact capabilities - pike contact service is not registered -> should panic", func(t *testing.T) { defer func() { if r := recover(); r == nil { t.Errorf("The code did not panic") @@ -506,10 +544,31 @@ func TestNewConfig(t *testing.T) { sl, WithDomain("test.com"), WithP2PCapabilities(), - WithPikeCapabilities(), + WithPikeContactCapabilities(), ) require.NoError(t, err) require.NotNil(t, c) assert.Equal(t, 7, len(c.callableCapabilities)) }) + + t.Run("with pike payment capabilities - pike payment service is not registered -> should panic", func(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Errorf("The code did not panic") + } + }() + + sl := &PaymailServiceLocator{} + sl.RegisterPaymailService(new(mockServiceProvider)) + + c, err := NewConfig( + sl, + WithDomain("test.com"), + WithP2PCapabilities(), + WithPikePaymentCapabilities(), + ) + require.NoError(t, err) + require.NotNil(t, c) + assert.Equal(t, 6, len(c.callableCapabilities)) + }) } diff --git a/server/interface.go b/server/interface.go index 37676c5..1ed9dc3 100644 --- a/server/interface.go +++ b/server/interface.go @@ -8,8 +8,9 @@ import ( ) type PaymailServiceLocator struct { - paymailService PaymailServiceProvider - pikeService PikeServiceProvider + paymailService PaymailServiceProvider + pikeContactService PikeContactServiceProvider + pikePaymentService PikePaymentServiceProvider } func (l *PaymailServiceLocator) RegisterPaymailService(s PaymailServiceProvider) { @@ -24,16 +25,28 @@ func (l *PaymailServiceLocator) GetPaymailService() PaymailServiceProvider { return l.paymailService } -func (l *PaymailServiceLocator) RegisterPikeService(s PikeServiceProvider) { - l.pikeService = s +func (l *PaymailServiceLocator) RegisterPikeContactService(s PikeContactServiceProvider) { + l.pikeContactService = s } -func (l *PaymailServiceLocator) GetPikeService() PikeServiceProvider { - if l.pikeService == nil { - panic("PikeServiceProvider was not registered") +func (l *PaymailServiceLocator) GetPikeContactService() PikeContactServiceProvider { + if l.pikeContactService == nil { + panic("PikeContactServiceProvider was not registered") } - return l.pikeService + return l.pikeContactService +} + +func (l *PaymailServiceLocator) RegisterPikePaymentService(s PikePaymentServiceProvider) { + l.pikePaymentService = s +} + +func (l *PaymailServiceLocator) GetPikePaymentService() PikePaymentServiceProvider { + if l.pikePaymentService == nil { + panic("PikePaymentServiceProvider was not registered") + } + + return l.pikePaymentService } // PaymailServiceProvider the paymail server interface that needs to be implemented @@ -70,10 +83,19 @@ type PaymailServiceProvider interface { ) error } -type PikeServiceProvider interface { +type PikeContactServiceProvider interface { AddContact( ctx context.Context, requesterPaymail string, contact *paymail.PikeContactRequestPayload, ) error } + +type PikePaymentServiceProvider interface { + CreatePikeDestinationResponse( + ctx context.Context, + alias, domain string, + satoshis uint64, + metaData *RequestMetadata, + ) (*paymail.PikePaymentOutputsResponse, error) +} diff --git a/server/mock_test.go b/server/mock_test.go index 7a62b28..c7a604e 100644 --- a/server/mock_test.go +++ b/server/mock_test.go @@ -52,3 +52,7 @@ func (m *mockServiceProvider) VerifyMerkleRoots(_ context.Context, _ []*spv.Merk func (m *mockServiceProvider) AddContact(ctx context.Context, requesterPaymail string, contact *paymail.PikeContactRequestPayload) error { return nil } + +func (m *mockServiceProvider) CreatePikeDestinationResponse(ctx context.Context, alias, domain string, satoshis uint64, metaData *RequestMetadata) (*paymail.PikePaymentOutputsResponse, error) { + return nil, nil +} diff --git a/server/p2p_payment_destination.go b/server/p2p_payment_destination.go index a573ea4..ad1a101 100644 --- a/server/p2p_payment_destination.go +++ b/server/p2p_payment_destination.go @@ -22,17 +22,6 @@ type p2pDestinationRequestBody struct { // // Specs: https://docs.moneybutton.com/docs/paymail-07-p2p-payment-destination.html func (c *Configuration) p2pDestination(context *gin.Context) { - incomingPaymail := context.Param(PaymailAddressParamName) - - // Parse, sanitize and basic validation - alias, domain, paymailAddress := paymail.SanitizePaymail(incomingPaymail) - if len(paymailAddress) == 0 { - ErrorResponse(context, ErrorInvalidParameter, "invalid paymail: "+incomingPaymail, http.StatusBadRequest) - return - } else if !c.IsAllowedDomain(domain) { - ErrorResponse(context, ErrorUnknownDomain, "domain unknown: "+domain, http.StatusBadRequest) - return - } var b p2pDestinationRequestBody err := context.Bind(&b) if err != nil { @@ -40,35 +29,15 @@ func (c *Configuration) p2pDestination(context *gin.Context) { return } - // Start the PaymentRequest - paymentRequest := &paymail.PaymentRequest{ - Satoshis: b.Satoshis, - } - - // Did we get some satoshis? - if paymentRequest.Satoshis == 0 { - ErrorResponse(context, ErrorMissingField, "missing parameter: satoshis", http.StatusBadRequest) - return - } - - // Create the metadata struct - md := CreateMetadata(context.Request, alias, domain, "") - md.PaymentDestination = paymentRequest - - // Get from the data layer - foundPaymail, err := c.actions.GetPaymailByAlias(context.Request.Context(), alias, domain, md) - if err != nil { - ErrorResponse(context, ErrorFindingPaymail, err.Error(), http.StatusExpectationFailed) - return - } else if foundPaymail == nil { - ErrorResponse(context, ErrorPaymailNotFound, "paymail not found", http.StatusNotFound) + alias, domain, md, ok := c.GetPaymailAndCreateMetadata(context, b.Satoshis) + if !ok { + // ErrorResponse already set up in GetPaymailAndCreateMetadata return } - // Create the response var response *paymail.PaymentDestinationPayload if response, err = c.actions.CreateP2PDestinationResponse( - context.Request.Context(), alias, domain, paymentRequest.Satoshis, md, + context.Request.Context(), alias, domain, b.Satoshis, md, ); err != nil { ErrorResponse(context, ErrorScript, "error creating output script(s): "+err.Error(), http.StatusExpectationFailed) return diff --git a/server/payment.go b/server/payment.go new file mode 100644 index 0000000..ea9ac63 --- /dev/null +++ b/server/payment.go @@ -0,0 +1,52 @@ +package server + +import ( + "github.com/bitcoin-sv/go-paymail" + "github.com/gin-gonic/gin" + "net/http" +) + +// GetPaymailAndCreateMetadata is a helper function to get the paymail from the request, check it in database and create the metadata based on that. +func (c *Configuration) GetPaymailAndCreateMetadata(context *gin.Context, satoshis uint64) (alias, domain string, md *RequestMetadata, ok bool) { + incomingPaymail := context.Param(PaymailAddressParamName) + + // Parse, sanitize and basic validation + alias, domain, paymailAddress := paymail.SanitizePaymail(incomingPaymail) + if len(paymailAddress) == 0 { + ErrorResponse(context, ErrorInvalidParameter, "invalid paymail: "+incomingPaymail, http.StatusBadRequest) + return + } + if !c.IsAllowedDomain(domain) { + ErrorResponse(context, ErrorUnknownDomain, "domain unknown: "+domain, http.StatusBadRequest) + return + } + + // Start the PaymentRequest + paymentRequest := &paymail.PaymentRequest{ + Satoshis: satoshis, + } + + // Did we get some satoshis? + if paymentRequest.Satoshis == 0 { + ErrorResponse(context, ErrorMissingField, "missing parameter: satoshis", http.StatusBadRequest) + return + } + + // Create the metadata struct + md = CreateMetadata(context.Request, alias, domain, "") + md.PaymentDestination = paymentRequest + + // Get from the data layer + foundPaymail, err := c.actions.GetPaymailByAlias(context.Request.Context(), alias, domain, md) + if err != nil { + ErrorResponse(context, ErrorFindingPaymail, err.Error(), http.StatusExpectationFailed) + return + } + if foundPaymail == nil { + ErrorResponse(context, ErrorPaymailNotFound, "paymail not found", http.StatusNotFound) + return + } + + ok = true + return +} diff --git a/server/pike.go b/server/pike.go index ca4a638..3284695 100644 --- a/server/pike.go +++ b/server/pike.go @@ -18,10 +18,38 @@ func (c *Configuration) pikeNewContact(rc *gin.Context) { return } - if err = c.pikeActions.AddContact(rc.Request.Context(), receiverPaymail, &requesterContact); err != nil { + if err = c.pikeContactActions.AddContact(rc.Request.Context(), receiverPaymail, &requesterContact); err != nil { ErrorResponse(rc, ErrorAddContactRequest, err.Error(), http.StatusExpectationFailed) return } rc.Status(http.StatusCreated) } + +func (c *Configuration) pikeGetPaymentDestinations(rc *gin.Context) { + var paymentDestinationRequest paymail.PikePaymentOutputsPayload + err := json.NewDecoder(rc.Request.Body).Decode(&paymentDestinationRequest) + defer func() { + _ = rc.Request.Body.Close() + }() + if err != nil { + ErrorResponse(rc, ErrorInvalidParameter, err.Error(), http.StatusBadRequest) + return + } + + alias, domain, md, ok := c.GetPaymailAndCreateMetadata(rc, paymentDestinationRequest.Amount) + if !ok { + // ErrorResponse already set up in GetPaymailAndCreateMetadata + return + } + + var response *paymail.PikePaymentOutputsResponse + if response, err = c.pikePaymentActions.CreatePikeDestinationResponse( + rc.Request.Context(), alias, domain, paymentDestinationRequest.Amount, md, + ); err != nil { + ErrorResponse(rc, ErrorScript, "error creating output script(s): "+err.Error(), http.StatusExpectationFailed) + return + } + + rc.JSON(http.StatusOK, response) +}