From 3b4439153430d5ccf211b4ff5ff5d84743c9c10e Mon Sep 17 00:00:00 2001 From: Zach Fedor Date: Thu, 12 Jun 2025 10:47:28 -0400 Subject: [PATCH 1/4] chore: run linter --- cmd/admin/migrate.go | 14 ++++++++++++++ go.mod | 4 ++++ go.sum | 8 ++++++++ 3 files changed, 26 insertions(+) diff --git a/cmd/admin/migrate.go b/cmd/admin/migrate.go index ede8bf97..59436a4f 100644 --- a/cmd/admin/migrate.go +++ b/cmd/admin/migrate.go @@ -13,6 +13,20 @@ import ( var MigrateCmd = &cli.Command{ Name: "migrate", Usage: "Migrate database up, down, or to a certain version", + Before: func(c *cli.Context) error { + db, closer, err := database.OpenFromCLI(c) + if err != nil { + return errors.WithStack(err) + } + defer closer.Close() + + // Check if migrations table exists (indicates versioned migration strategy is in place) + if !db.Migrator().HasTable("migrations") { + return errors.New("Database has not been initialized with versioned migration strategy. Please run 'singularity admin init' first to migrate your database to the new migration system.") + } + + return nil + }, Subcommands: []*cli.Command{ { Name: "up", diff --git a/go.mod b/go.mod index fc1ffe8c..f5a80807 100644 --- a/go.mod +++ b/go.mod @@ -86,9 +86,13 @@ require ( ) require ( + github.com/bitfield/gotestdox v0.2.2 // indirect + github.com/dnephin/pflag v1.0.7 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/shirou/gopsutil/v3 v3.23.3 // indirect + github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c // indirect + gotest.tools/gotestsum v1.12.2 // indirect ) require ( diff --git a/go.sum b/go.sum index 8d929c15..1608f837 100644 --- a/go.sum +++ b/go.sum @@ -97,6 +97,8 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY= github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0= +github.com/bitfield/gotestdox v0.2.2 h1:x6RcPAbBbErKLnapz1QeAlf3ospg8efBsedU93CDsnE= +github.com/bitfield/gotestdox v0.2.2/go.mod h1:D+gwtS0urjBrzguAkTM2wodsTQYFHdpx8eqRJ3N+9pY= github.com/bradfitz/go-smtpd v0.0.0-20170404230938-deb6d6237625/go.mod h1:HYsPBTaaSFSlLx/70C2HPIMNZpVV8+vt/A+FMnYP11g= github.com/brianvoe/gofakeit/v6 v6.23.2 h1:lVde18uhad5wII/f5RMVFLtdQNE0HaGFuBUXmYKk8i8= github.com/brianvoe/gofakeit/v6 v6.23.2/go.mod h1:Ow6qC71xtwm79anlwKRlWZW6zVq9D2XHE4QSSMP/rU8= @@ -162,6 +164,8 @@ github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0/go.mod h1:v57UDF4pDQJcEfFUCRop3 github.com/dlespiau/covertool v0.0.0-20180314162135-b0c4c6d0583a/go.mod h1:/eQMcW3eA1bzKx23ZYI2H3tXPdJB5JWYTHzoUPBvQY4= github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI= github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= +github.com/dnephin/pflag v1.0.7 h1:oxONGlWxhmUct0YzKTgrpQv9AUA1wtPBn7zuSjJqptk= +github.com/dnephin/pflag v1.0.7/go.mod h1:uxE91IoWURlOiTUIA8Mq5ZZkAv3dPUfZNaT80Zm7OQE= github.com/docker/distribution v2.8.2+incompatible h1:T3de5rq0dB1j30rp0sA2rER+m322EBzniBPB6ZIzuh8= github.com/docker/distribution v2.8.2+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= github.com/docker/docker v24.0.5+incompatible h1:WmgcE4fxyI6EEXxBRxsHnZXrO1pQ3smi0k/jho4HLeY= @@ -466,6 +470,8 @@ github.com/google/pprof v0.0.0-20250202011525-fc3143867406/go.mod h1:vavhavw2zAx github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/s2a-go v0.1.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o= github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -1763,6 +1769,8 @@ gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8= gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo= gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= +gotest.tools/gotestsum v1.12.2 h1:eli4tu9Q2D/ogDsEGSr8XfQfl7mT0JsGOG6DFtUiZ/Q= +gotest.tools/gotestsum v1.12.2/go.mod h1:kjRtCglPZVsSU0hFHX3M5VWBM6Y63emHuB14ER1/sow= gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= grpc.go4.org v0.0.0-20170609214715-11d0a25b4919/go.mod h1:77eQGdRu53HpSqPFJFmuJdjuHRquDANNeA4x7B8WQ9o= From 9928caee159513ebb383b590f4d47754cc545a4e Mon Sep 17 00:00:00 2001 From: Zach Fedor Date: Wed, 18 Jun 2025 10:43:21 -0400 Subject: [PATCH 2/4] feat: add init wallet handler Adds command and API endpoint to initialize a newly created wallet with the ActorID on chain. Once a wallet is created with `singularity wallet create` and it's been sent Fil or DataCap, run `singularity wallet init
` to run a lookup and update it's ActorID field. --- api/api.go | 1 + api/api_test.go | 11 + .../http/wallet/init_wallet_parameters.go | 151 ++++++++++ .../http/wallet/init_wallet_responses.go | 258 ++++++++++++++++++ client/swagger/http/wallet/wallet_client.go | 40 +++ cmd/app.go | 1 + cmd/wallet/init.go | 32 +++ cmd/wallet_test.go | 34 ++- docs/en/SUMMARY.md | 1 + docs/en/cli-reference/wallet/README.md | 1 + docs/en/cli-reference/wallet/init.md | 14 + docs/en/web-api-reference/wallet.md | 4 + docs/swagger/docs.go | 41 +++ docs/swagger/swagger.json | 41 +++ docs/swagger/swagger.yaml | 27 ++ handler/wallet/init.go | 68 +++++ handler/wallet/init_test.go | 50 ++++ handler/wallet/interface.go | 11 + 18 files changed, 784 insertions(+), 2 deletions(-) create mode 100644 client/swagger/http/wallet/init_wallet_parameters.go create mode 100644 client/swagger/http/wallet/init_wallet_responses.go create mode 100644 cmd/wallet/init.go create mode 100644 docs/en/cli-reference/wallet/init.md create mode 100644 handler/wallet/init.go create mode 100644 handler/wallet/init_test.go diff --git a/api/api.go b/api/api.go index d4a6f974..aa017ff5 100644 --- a/api/api.go +++ b/api/api.go @@ -349,6 +349,7 @@ func (s *Server) setupRoutes(e *echo.Echo) { e.POST("/api/wallet/create", s.toEchoHandler(s.walletHandler.CreateHandler)) e.POST("/api/wallet", s.toEchoHandler(s.walletHandler.ImportHandler)) e.GET("/api/wallet", s.toEchoHandler(s.walletHandler.ListHandler)) + e.POST("/api/wallet/:address/init", s.toEchoHandler(s.walletHandler.InitHandler)) e.DELETE("/api/wallet/:address", s.toEchoHandler(s.walletHandler.RemoveHandler)) // Wallet Association diff --git a/api/api_test.go b/api/api_test.go index da4f2ab1..f8768dd2 100644 --- a/api/api_test.go +++ b/api/api_test.go @@ -190,6 +190,8 @@ func setupMockWallet() wallet.Handler { Return(&model.Preparation{}, nil) m.On("ImportHandler", mock.Anything, mock.Anything, mock.Anything, mock.Anything). Return(&model.Wallet{}, nil) + m.On("InitHandler", mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(&model.Wallet{}, nil) m.On("ListHandler", mock.Anything, mock.Anything). Return([]model.Wallet{{}}, nil) m.On("ListAttachedHandler", mock.Anything, mock.Anything, "id"). @@ -321,6 +323,15 @@ func TestAllAPIs(t *testing.T) { require.True(t, resp.IsSuccess()) require.NotNil(t, resp.Payload) }) + t.Run("InitWallet", func(t *testing.T) { + resp, err := client.Wallet.InitWallet(&wallet2.InitWalletParams{ + Context: ctx, + Address: "wallet", + }) + require.NoError(t, err) + require.True(t, resp.IsSuccess()) + require.NotNil(t, resp.Payload) + }) t.Run("ListWallets", func(t *testing.T) { resp, err := client.Wallet.ListWallets(&wallet2.ListWalletsParams{ Context: ctx, diff --git a/client/swagger/http/wallet/init_wallet_parameters.go b/client/swagger/http/wallet/init_wallet_parameters.go new file mode 100644 index 00000000..3a8fb76d --- /dev/null +++ b/client/swagger/http/wallet/init_wallet_parameters.go @@ -0,0 +1,151 @@ +// Code generated by go-swagger; DO NOT EDIT. + +package wallet + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the swagger generate command + +import ( + "context" + "net/http" + "time" + + "github.com/go-openapi/errors" + "github.com/go-openapi/runtime" + cr "github.com/go-openapi/runtime/client" + "github.com/go-openapi/strfmt" +) + +// NewInitWalletParams creates a new InitWalletParams object, +// with the default timeout for this client. +// +// Default values are not hydrated, since defaults are normally applied by the API server side. +// +// To enforce default values in parameter, use SetDefaults or WithDefaults. +func NewInitWalletParams() *InitWalletParams { + return &InitWalletParams{ + timeout: cr.DefaultTimeout, + } +} + +// NewInitWalletParamsWithTimeout creates a new InitWalletParams object +// with the ability to set a timeout on a request. +func NewInitWalletParamsWithTimeout(timeout time.Duration) *InitWalletParams { + return &InitWalletParams{ + timeout: timeout, + } +} + +// NewInitWalletParamsWithContext creates a new InitWalletParams object +// with the ability to set a context for a request. +func NewInitWalletParamsWithContext(ctx context.Context) *InitWalletParams { + return &InitWalletParams{ + Context: ctx, + } +} + +// NewInitWalletParamsWithHTTPClient creates a new InitWalletParams object +// with the ability to set a custom HTTPClient for a request. +func NewInitWalletParamsWithHTTPClient(client *http.Client) *InitWalletParams { + return &InitWalletParams{ + HTTPClient: client, + } +} + +/* +InitWalletParams contains all the parameters to send to the API endpoint + + for the init wallet operation. + + Typically these are written to a http.Request. +*/ +type InitWalletParams struct { + + /* Address. + + Address + */ + Address string + + timeout time.Duration + Context context.Context + HTTPClient *http.Client +} + +// WithDefaults hydrates default values in the init wallet params (not the query body). +// +// All values with no default are reset to their zero value. +func (o *InitWalletParams) WithDefaults() *InitWalletParams { + o.SetDefaults() + return o +} + +// SetDefaults hydrates default values in the init wallet params (not the query body). +// +// All values with no default are reset to their zero value. +func (o *InitWalletParams) SetDefaults() { + // no default values defined for this parameter +} + +// WithTimeout adds the timeout to the init wallet params +func (o *InitWalletParams) WithTimeout(timeout time.Duration) *InitWalletParams { + o.SetTimeout(timeout) + return o +} + +// SetTimeout adds the timeout to the init wallet params +func (o *InitWalletParams) SetTimeout(timeout time.Duration) { + o.timeout = timeout +} + +// WithContext adds the context to the init wallet params +func (o *InitWalletParams) WithContext(ctx context.Context) *InitWalletParams { + o.SetContext(ctx) + return o +} + +// SetContext adds the context to the init wallet params +func (o *InitWalletParams) SetContext(ctx context.Context) { + o.Context = ctx +} + +// WithHTTPClient adds the HTTPClient to the init wallet params +func (o *InitWalletParams) WithHTTPClient(client *http.Client) *InitWalletParams { + o.SetHTTPClient(client) + return o +} + +// SetHTTPClient adds the HTTPClient to the init wallet params +func (o *InitWalletParams) SetHTTPClient(client *http.Client) { + o.HTTPClient = client +} + +// WithAddress adds the address to the init wallet params +func (o *InitWalletParams) WithAddress(address string) *InitWalletParams { + o.SetAddress(address) + return o +} + +// SetAddress adds the address to the init wallet params +func (o *InitWalletParams) SetAddress(address string) { + o.Address = address +} + +// WriteToRequest writes these params to a swagger request +func (o *InitWalletParams) WriteToRequest(r runtime.ClientRequest, reg strfmt.Registry) error { + + if err := r.SetTimeout(o.timeout); err != nil { + return err + } + var res []error + + // path param address + if err := r.SetPathParam("address", o.Address); err != nil { + return err + } + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} diff --git a/client/swagger/http/wallet/init_wallet_responses.go b/client/swagger/http/wallet/init_wallet_responses.go new file mode 100644 index 00000000..6491c25e --- /dev/null +++ b/client/swagger/http/wallet/init_wallet_responses.go @@ -0,0 +1,258 @@ +// Code generated by go-swagger; DO NOT EDIT. + +package wallet + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the swagger generate command + +import ( + "encoding/json" + "fmt" + "io" + + "github.com/go-openapi/runtime" + "github.com/go-openapi/strfmt" + + "github.com/data-preservation-programs/singularity/client/swagger/models" +) + +// InitWalletReader is a Reader for the InitWallet structure. +type InitWalletReader struct { + formats strfmt.Registry +} + +// ReadResponse reads a server response into the received o. +func (o *InitWalletReader) ReadResponse(response runtime.ClientResponse, consumer runtime.Consumer) (interface{}, error) { + switch response.Code() { + case 200: + result := NewInitWalletOK() + if err := result.readResponse(response, consumer, o.formats); err != nil { + return nil, err + } + return result, nil + case 400: + result := NewInitWalletBadRequest() + if err := result.readResponse(response, consumer, o.formats); err != nil { + return nil, err + } + return nil, result + case 500: + result := NewInitWalletInternalServerError() + if err := result.readResponse(response, consumer, o.formats); err != nil { + return nil, err + } + return nil, result + default: + return nil, runtime.NewAPIError("[POST /wallet/{address}/init] InitWallet", response, response.Code()) + } +} + +// NewInitWalletOK creates a InitWalletOK with default headers values +func NewInitWalletOK() *InitWalletOK { + return &InitWalletOK{} +} + +/* +InitWalletOK describes a response with status code 200, with default header values. + +OK +*/ +type InitWalletOK struct { + Payload *models.ModelWallet +} + +// IsSuccess returns true when this init wallet o k response has a 2xx status code +func (o *InitWalletOK) IsSuccess() bool { + return true +} + +// IsRedirect returns true when this init wallet o k response has a 3xx status code +func (o *InitWalletOK) IsRedirect() bool { + return false +} + +// IsClientError returns true when this init wallet o k response has a 4xx status code +func (o *InitWalletOK) IsClientError() bool { + return false +} + +// IsServerError returns true when this init wallet o k response has a 5xx status code +func (o *InitWalletOK) IsServerError() bool { + return false +} + +// IsCode returns true when this init wallet o k response a status code equal to that given +func (o *InitWalletOK) IsCode(code int) bool { + return code == 200 +} + +// Code gets the status code for the init wallet o k response +func (o *InitWalletOK) Code() int { + return 200 +} + +func (o *InitWalletOK) Error() string { + payload, _ := json.Marshal(o.Payload) + return fmt.Sprintf("[POST /wallet/{address}/init][%d] initWalletOK %s", 200, payload) +} + +func (o *InitWalletOK) String() string { + payload, _ := json.Marshal(o.Payload) + return fmt.Sprintf("[POST /wallet/{address}/init][%d] initWalletOK %s", 200, payload) +} + +func (o *InitWalletOK) GetPayload() *models.ModelWallet { + return o.Payload +} + +func (o *InitWalletOK) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error { + + o.Payload = new(models.ModelWallet) + + // response payload + if err := consumer.Consume(response.Body(), o.Payload); err != nil && err != io.EOF { + return err + } + + return nil +} + +// NewInitWalletBadRequest creates a InitWalletBadRequest with default headers values +func NewInitWalletBadRequest() *InitWalletBadRequest { + return &InitWalletBadRequest{} +} + +/* +InitWalletBadRequest describes a response with status code 400, with default header values. + +Bad Request +*/ +type InitWalletBadRequest struct { + Payload *models.APIHTTPError +} + +// IsSuccess returns true when this init wallet bad request response has a 2xx status code +func (o *InitWalletBadRequest) IsSuccess() bool { + return false +} + +// IsRedirect returns true when this init wallet bad request response has a 3xx status code +func (o *InitWalletBadRequest) IsRedirect() bool { + return false +} + +// IsClientError returns true when this init wallet bad request response has a 4xx status code +func (o *InitWalletBadRequest) IsClientError() bool { + return true +} + +// IsServerError returns true when this init wallet bad request response has a 5xx status code +func (o *InitWalletBadRequest) IsServerError() bool { + return false +} + +// IsCode returns true when this init wallet bad request response a status code equal to that given +func (o *InitWalletBadRequest) IsCode(code int) bool { + return code == 400 +} + +// Code gets the status code for the init wallet bad request response +func (o *InitWalletBadRequest) Code() int { + return 400 +} + +func (o *InitWalletBadRequest) Error() string { + payload, _ := json.Marshal(o.Payload) + return fmt.Sprintf("[POST /wallet/{address}/init][%d] initWalletBadRequest %s", 400, payload) +} + +func (o *InitWalletBadRequest) String() string { + payload, _ := json.Marshal(o.Payload) + return fmt.Sprintf("[POST /wallet/{address}/init][%d] initWalletBadRequest %s", 400, payload) +} + +func (o *InitWalletBadRequest) GetPayload() *models.APIHTTPError { + return o.Payload +} + +func (o *InitWalletBadRequest) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error { + + o.Payload = new(models.APIHTTPError) + + // response payload + if err := consumer.Consume(response.Body(), o.Payload); err != nil && err != io.EOF { + return err + } + + return nil +} + +// NewInitWalletInternalServerError creates a InitWalletInternalServerError with default headers values +func NewInitWalletInternalServerError() *InitWalletInternalServerError { + return &InitWalletInternalServerError{} +} + +/* +InitWalletInternalServerError describes a response with status code 500, with default header values. + +Internal Server Error +*/ +type InitWalletInternalServerError struct { + Payload *models.APIHTTPError +} + +// IsSuccess returns true when this init wallet internal server error response has a 2xx status code +func (o *InitWalletInternalServerError) IsSuccess() bool { + return false +} + +// IsRedirect returns true when this init wallet internal server error response has a 3xx status code +func (o *InitWalletInternalServerError) IsRedirect() bool { + return false +} + +// IsClientError returns true when this init wallet internal server error response has a 4xx status code +func (o *InitWalletInternalServerError) IsClientError() bool { + return false +} + +// IsServerError returns true when this init wallet internal server error response has a 5xx status code +func (o *InitWalletInternalServerError) IsServerError() bool { + return true +} + +// IsCode returns true when this init wallet internal server error response a status code equal to that given +func (o *InitWalletInternalServerError) IsCode(code int) bool { + return code == 500 +} + +// Code gets the status code for the init wallet internal server error response +func (o *InitWalletInternalServerError) Code() int { + return 500 +} + +func (o *InitWalletInternalServerError) Error() string { + payload, _ := json.Marshal(o.Payload) + return fmt.Sprintf("[POST /wallet/{address}/init][%d] initWalletInternalServerError %s", 500, payload) +} + +func (o *InitWalletInternalServerError) String() string { + payload, _ := json.Marshal(o.Payload) + return fmt.Sprintf("[POST /wallet/{address}/init][%d] initWalletInternalServerError %s", 500, payload) +} + +func (o *InitWalletInternalServerError) GetPayload() *models.APIHTTPError { + return o.Payload +} + +func (o *InitWalletInternalServerError) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error { + + o.Payload = new(models.APIHTTPError) + + // response payload + if err := consumer.Consume(response.Body(), o.Payload); err != nil && err != io.EOF { + return err + } + + return nil +} diff --git a/client/swagger/http/wallet/wallet_client.go b/client/swagger/http/wallet/wallet_client.go index e3373fe6..e4f14425 100644 --- a/client/swagger/http/wallet/wallet_client.go +++ b/client/swagger/http/wallet/wallet_client.go @@ -60,6 +60,8 @@ type ClientService interface { ImportWallet(params *ImportWalletParams, opts ...ClientOption) (*ImportWalletOK, error) + InitWallet(params *InitWalletParams, opts ...ClientOption) (*InitWalletOK, error) + ListWallets(params *ListWalletsParams, opts ...ClientOption) (*ListWalletsOK, error) RemoveWallet(params *RemoveWalletParams, opts ...ClientOption) (*RemoveWalletNoContent, error) @@ -143,6 +145,44 @@ func (a *Client) ImportWallet(params *ImportWalletParams, opts ...ClientOption) panic(msg) } +/* +InitWallet initializes a newly created wallet +*/ +func (a *Client) InitWallet(params *InitWalletParams, opts ...ClientOption) (*InitWalletOK, error) { + // TODO: Validate the params before sending + if params == nil { + params = NewInitWalletParams() + } + op := &runtime.ClientOperation{ + ID: "InitWallet", + Method: "POST", + PathPattern: "/wallet/{address}/init", + ProducesMediaTypes: []string{"application/json"}, + ConsumesMediaTypes: []string{"application/json"}, + Schemes: []string{"http"}, + Params: params, + Reader: &InitWalletReader{formats: a.formats}, + Context: params.Context, + Client: params.HTTPClient, + } + for _, opt := range opts { + opt(op) + } + + result, err := a.transport.Submit(op) + if err != nil { + return nil, err + } + success, ok := result.(*InitWalletOK) + if ok { + return success, nil + } + // unexpected success response + // safeguard: normally, absent a default response, unknown success responses return an error above: so this is a codegen issue + msg := fmt.Sprintf("unexpected success response for InitWallet: API contract not enforced by server. Client expected to get an error, but got: %T", result) + panic(msg) +} + /* ListWallets lists all imported wallets */ diff --git a/cmd/app.go b/cmd/app.go index d59a6319..806ac16b 100644 --- a/cmd/app.go +++ b/cmd/app.go @@ -168,6 +168,7 @@ Upgrading: Subcommands: []*cli.Command{ wallet.CreateCmd, wallet.ImportCmd, + wallet.InitCmd, wallet.ListCmd, wallet.RemoveCmd, }, diff --git a/cmd/wallet/init.go b/cmd/wallet/init.go new file mode 100644 index 00000000..097f1d7f --- /dev/null +++ b/cmd/wallet/init.go @@ -0,0 +1,32 @@ +package wallet + +import ( + "github.com/cockroachdb/errors" + "github.com/data-preservation-programs/singularity/cmd/cliutil" + "github.com/data-preservation-programs/singularity/database" + "github.com/data-preservation-programs/singularity/handler/wallet" + "github.com/data-preservation-programs/singularity/util" + "github.com/urfave/cli/v2" +) + +var InitCmd = &cli.Command{ + Name: "init", + Usage: "Initialize a wallet", + ArgsUsage: "
", + Before: cliutil.CheckNArgs, + Action: func(c *cli.Context) error { + db, closer, err := database.OpenFromCLI(c) + if err != nil { + return errors.WithStack(err) + } + defer closer.Close() + + lotusClient := util.NewLotusClient(c.String("lotus-api"), c.String("lotus-token")) + w, err := wallet.Default.InitHandler(c.Context, db, lotusClient, c.Args().Get(0)) + if err != nil { + return errors.WithStack(err) + } + cliutil.Print(c, w) + return nil + }, +} diff --git a/cmd/wallet_test.go b/cmd/wallet_test.go index f10061d0..01fb884f 100644 --- a/cmd/wallet_test.go +++ b/cmd/wallet_test.go @@ -6,6 +6,7 @@ import ( "path/filepath" "testing" + "github.com/cockroachdb/errors" "github.com/data-preservation-programs/singularity/cmd/cliutil" "github.com/data-preservation-programs/singularity/handler/wallet" "github.com/data-preservation-programs/singularity/model" @@ -30,17 +31,30 @@ func TestWalletCreate(t *testing.T) { mockHandler := new(wallet.MockWallet) defer swapWalletHandler(mockHandler)() mockHandler.On("CreateHandler", mock.Anything, mock.Anything, mock.Anything).Return(&model.Wallet{ - ActorID: "id", Address: "address", PrivateKey: "private", }, nil) _, _, err := runner.Run(ctx, "singularity wallet create") require.NoError(t, err) - _, _, err = runner.Run(ctx, "singularity --verbose wallet create") + _, _, err = runner.Run(ctx, "singularity wallet create secp256k1") + require.NoError(t, err) + _, _, err = runner.Run(ctx, "singularity wallet create bls") require.NoError(t, err) }) } +func TestWalletCreate_BadType(t *testing.T) { + testutil.OneWithoutReset(t, func(ctx context.Context, t *testing.T, db *gorm.DB) { + runner := NewRunner() + defer runner.Save(t) + mockHandler := new(wallet.MockWallet) + defer swapWalletHandler(mockHandler)() + mockHandler.On("CreateHandler", mock.Anything, mock.Anything, mock.Anything).Return((*model.Wallet)(nil), errors.New("unsupported key type: not-a-real-type")) + _, _, err := runner.Run(ctx, "singularity wallet create not-a-real-type") + require.Error(t, err) + }) +} + func TestWalletImport(t *testing.T) { testutil.OneWithoutReset(t, func(ctx context.Context, t *testing.T, db *gorm.DB) { tmp := t.TempDir() @@ -62,6 +76,22 @@ func TestWalletImport(t *testing.T) { }) } +func TestWalletInit(t *testing.T) { + testutil.OneWithoutReset(t, func(ctx context.Context, t *testing.T, db *gorm.DB) { + runner := NewRunner() + defer runner.Save(t) + mockHandler := new(wallet.MockWallet) + defer swapWalletHandler(mockHandler)() + mockHandler.On("InitHandler", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(&model.Wallet{ + ActorID: "id", + Address: "address", + PrivateKey: "private", + }, nil) + _, _, err := runner.Run(ctx, "singularity wallet init xxx") + require.NoError(t, err) + }) +} + func TestWalletList(t *testing.T) { testutil.OneWithoutReset(t, func(ctx context.Context, t *testing.T, db *gorm.DB) { runner := NewRunner() diff --git a/docs/en/SUMMARY.md b/docs/en/SUMMARY.md index c9d134c1..706885da 100644 --- a/docs/en/SUMMARY.md +++ b/docs/en/SUMMARY.md @@ -69,6 +69,7 @@ * [Wallet](cli-reference/wallet/README.md) * [Create](cli-reference/wallet/create.md) * [Import](cli-reference/wallet/import.md) + * [Init](cli-reference/wallet/init.md) * [List](cli-reference/wallet/list.md) * [Remove](cli-reference/wallet/remove.md) * [Storage](cli-reference/storage/README.md) diff --git a/docs/en/cli-reference/wallet/README.md b/docs/en/cli-reference/wallet/README.md index 1710883b..883aa4e6 100644 --- a/docs/en/cli-reference/wallet/README.md +++ b/docs/en/cli-reference/wallet/README.md @@ -11,6 +11,7 @@ USAGE: COMMANDS: create Create a new wallet import Import a wallet from exported private key + init Initialize a wallet list List all imported wallets remove Remove a wallet help, h Shows a list of commands or help for one command diff --git a/docs/en/cli-reference/wallet/init.md b/docs/en/cli-reference/wallet/init.md new file mode 100644 index 00000000..2b49de28 --- /dev/null +++ b/docs/en/cli-reference/wallet/init.md @@ -0,0 +1,14 @@ +# Initialize a wallet + +{% code fullWidth="true" %} +``` +NAME: + singularity wallet init - Initialize a wallet + +USAGE: + singularity wallet init [command options]
+ +OPTIONS: + --help, -h show help +``` +{% endcode %} diff --git a/docs/en/web-api-reference/wallet.md b/docs/en/web-api-reference/wallet.md index 28a14954..fc90ed4e 100644 --- a/docs/en/web-api-reference/wallet.md +++ b/docs/en/web-api-reference/wallet.md @@ -16,3 +16,7 @@ [https://raw.githubusercontent.com/data-preservation-programs/singularity/main/docs/swagger/swagger.yaml](https://raw.githubusercontent.com/data-preservation-programs/singularity/main/docs/swagger/swagger.yaml) {% endswagger %} +{% swagger src="https://raw.githubusercontent.com/data-preservation-programs/singularity/main/docs/swagger/swagger.yaml" path="/wallet/{address}/init" method="post" %} +[https://raw.githubusercontent.com/data-preservation-programs/singularity/main/docs/swagger/swagger.yaml](https://raw.githubusercontent.com/data-preservation-programs/singularity/main/docs/swagger/swagger.yaml) +{% endswagger %} + diff --git a/docs/swagger/docs.go b/docs/swagger/docs.go index f4db4ee4..4c83f94a 100644 --- a/docs/swagger/docs.go +++ b/docs/swagger/docs.go @@ -5612,6 +5612,47 @@ const docTemplate = `{ } } } + }, + "/wallet/{address}/init": { + "post": { + "produces": [ + "application/json" + ], + "tags": [ + "Wallet" + ], + "summary": "Initialize a newly created wallet", + "operationId": "InitWallet", + "parameters": [ + { + "type": "string", + "description": "Address", + "name": "address", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/model.Wallet" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.HTTPError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.HTTPError" + } + } + } + } } }, "definitions": { diff --git a/docs/swagger/swagger.json b/docs/swagger/swagger.json index 5244a1e3..ecc20a2a 100644 --- a/docs/swagger/swagger.json +++ b/docs/swagger/swagger.json @@ -5606,6 +5606,47 @@ } } } + }, + "/wallet/{address}/init": { + "post": { + "produces": [ + "application/json" + ], + "tags": [ + "Wallet" + ], + "summary": "Initialize a newly created wallet", + "operationId": "InitWallet", + "parameters": [ + { + "type": "string", + "description": "Address", + "name": "address", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/model.Wallet" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.HTTPError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.HTTPError" + } + } + } + } } }, "definitions": { diff --git a/docs/swagger/swagger.yaml b/docs/swagger/swagger.yaml index de07188a..7c5eac39 100644 --- a/docs/swagger/swagger.yaml +++ b/docs/swagger/swagger.yaml @@ -11886,6 +11886,33 @@ paths: summary: Remove a wallet tags: - Wallet + /wallet/{address}/init: + post: + operationId: InitWallet + parameters: + - description: Address + in: path + name: address + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/model.Wallet' + "400": + description: Bad Request + schema: + $ref: '#/definitions/api.HTTPError' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.HTTPError' + summary: Initialize a newly created wallet + tags: + - Wallet /wallet/create: post: consumes: diff --git a/handler/wallet/init.go b/handler/wallet/init.go new file mode 100644 index 00000000..d1f5d41d --- /dev/null +++ b/handler/wallet/init.go @@ -0,0 +1,68 @@ +package wallet + +import ( + "context" + + "github.com/cockroachdb/errors" + "github.com/data-preservation-programs/singularity/database" + "github.com/data-preservation-programs/singularity/handler/handlererror" + "github.com/data-preservation-programs/singularity/model" + "github.com/ybbus/jsonrpc/v3" + "gorm.io/gorm" +) + +// @ID InitWallet +// @Summary Initialize a newly created wallet +// @Tags Wallet +// @Produce json +// @Param address path string true "Address" +// @Success 200 {object} model.Wallet +// @Failure 400 {object} api.HTTPError +// @Failure 500 {object} api.HTTPError +// @Router /wallet/{address}/init [post] +func _() {} + +// InitHandler marks a new wallet created from offline keypair generation as initialized by updating its ActorID +// +// Parameters: +// - ctx: The context for database transactions and other operations. +// - db: A pointer to the gorm.DB instance representing the database connection. +// - lotusClient: The RPC client used to interact with a Lotus node for actor lookup. +// - address: The address or ID of the wallet to be initialized. +// +// Returns: +// - A pointer to the initialized Wallet model if successful. +// - An error, if any occurred during the database insert operation. +func (DefaultHandler) InitHandler( + ctx context.Context, + db *gorm.DB, + lotusClient jsonrpc.RPCClient, + address string, +) (*model.Wallet, error) { + db = db.WithContext(ctx) + var wallet model.Wallet + err := wallet.FindByIDOrAddr(db, address) + if err != nil { + return nil, errors.Wrap(err, "failed to find wallet") + } + + if wallet.ActorID != "" { + return &wallet, nil + } + + var result string + err = lotusClient.CallFor(ctx, &result, "Filecoin.StateLookupID", wallet.Address, nil) + if err != nil { + return nil, errors.Join(handlererror.ErrInvalidParameter, errors.Wrap(err, "failed to lookup actor ID")) + } + + wallet.ActorID = result + err = database.DoRetry(ctx, func() error { + return db.Save(&wallet).Error + }) + if err != nil { + return nil, errors.WithStack(err) + } + + return &wallet, nil +} diff --git a/handler/wallet/init_test.go b/handler/wallet/init_test.go new file mode 100644 index 00000000..6ac53eee --- /dev/null +++ b/handler/wallet/init_test.go @@ -0,0 +1,50 @@ +package wallet + +import ( + "context" + "testing" + + "github.com/data-preservation-programs/singularity/model" + "github.com/data-preservation-programs/singularity/util" + "github.com/data-preservation-programs/singularity/util/testutil" + "github.com/stretchr/testify/require" + "gorm.io/gorm" +) + +func TestInitHandler(t *testing.T) { + testutil.All(t, func(ctx context.Context, t *testing.T, db *gorm.DB) { + lotusClient := util.NewLotusClient("https://api.node.glif.io/rpc/v0", "") + + t.Run("success", func(t *testing.T) { + err := db.Create(&model.Wallet{ + Address: testutil.TestWalletAddr, + PrivateKey: testutil.TestPrivateKeyHex, + }).Error + require.NoError(t, err) + w, err := Default.InitHandler(ctx, db, lotusClient, testutil.TestWalletAddr) + require.NoError(t, err) + require.NotEmpty(t, w.PrivateKey) + require.Equal(t, w.Address, testutil.TestWalletAddr) + require.NotEmpty(t, w.ActorID) + + // Running again on an initialized wallet should not change the wallet + w2, err := Default.InitHandler(ctx, db, lotusClient, testutil.TestWalletAddr) + require.NoError(t, err) + require.Equal(t, w.ActorID, w2.ActorID) + }) + + t.Run("uninitialized-address", func(t *testing.T) { + err := db.Create(&model.Wallet{ + Address: "f100", + }).Error + require.NoError(t, err) + _, err = Default.InitHandler(ctx, db, lotusClient, "f100") + require.ErrorContains(t, err, "failed to lookup actor ID") + }) + + t.Run("unknown-address", func(t *testing.T) { + _, err := Default.InitHandler(ctx, db, lotusClient, "unknown-address") + require.ErrorContains(t, err, "failed to find wallet") + }) + }) +} diff --git a/handler/wallet/interface.go b/handler/wallet/interface.go index 0a88d848..f0cb0305 100644 --- a/handler/wallet/interface.go +++ b/handler/wallet/interface.go @@ -34,6 +34,12 @@ type Handler interface { lotusClient jsonrpc.RPCClient, request ImportRequest, ) (*model.Wallet, error) + InitHandler( + ctx context.Context, + db *gorm.DB, + lotusClient jsonrpc.RPCClient, + address string, + ) (*model.Wallet, error) ListHandler( ctx context.Context, db *gorm.DB, @@ -80,6 +86,11 @@ func (m *MockWallet) ImportHandler(ctx context.Context, db *gorm.DB, lotusClient return args.Get(0).(*model.Wallet), args.Error(1) } +func (m *MockWallet) InitHandler(ctx context.Context, db *gorm.DB, lotusClient jsonrpc.RPCClient, wallet string) (*model.Wallet, error) { + args := m.Called(ctx, db, lotusClient, wallet) + return args.Get(0).(*model.Wallet), args.Error(1) +} + func (m *MockWallet) ListHandler(ctx context.Context, db *gorm.DB) ([]model.Wallet, error) { args := m.Called(ctx, db) return args.Get(0).([]model.Wallet), args.Error(1) From 95bee3822954504319caaaeb80c3b0c212bcfae4 Mon Sep 17 00:00:00 2001 From: Zach Fedor Date: Thu, 12 Jun 2025 14:30:23 -0400 Subject: [PATCH 3/4] feat: add wallet update command and endpoint Adds the ability to update name, location, and contact fields for wallets. Fields like address, actor ID, balances, and type may not be updated. --- api/api.go | 1 + .../http/wallet/update_wallet_parameters.go | 175 +++++++++ .../http/wallet/update_wallet_responses.go | 334 ++++++++++++++++++ client/swagger/http/wallet/wallet_client.go | 40 +++ .../swagger/models/wallet_update_request.go | 56 +++ cmd/app.go | 1 + cmd/wallet/update.go | 88 +++++ cmd/wallet_test.go | 42 +++ docs/en/SUMMARY.md | 1 + docs/en/cli-reference/wallet/README.md | 1 + docs/en/cli-reference/wallet/update.md | 34 ++ docs/en/web-api-reference/wallet.md | 4 + docs/swagger/docs.go | 76 ++++ docs/swagger/swagger.json | 76 ++++ docs/swagger/swagger.yaml | 51 +++ go.mod | 4 - go.sum | 8 - handler/wallet/interface.go | 11 + handler/wallet/update.go | 101 ++++++ handler/wallet/update_test.go | 108 ++++++ 20 files changed, 1200 insertions(+), 12 deletions(-) create mode 100644 client/swagger/http/wallet/update_wallet_parameters.go create mode 100644 client/swagger/http/wallet/update_wallet_responses.go create mode 100644 client/swagger/models/wallet_update_request.go create mode 100644 cmd/wallet/update.go create mode 100644 docs/en/cli-reference/wallet/update.md create mode 100644 handler/wallet/update.go create mode 100644 handler/wallet/update_test.go diff --git a/api/api.go b/api/api.go index aa017ff5..2abd51b4 100644 --- a/api/api.go +++ b/api/api.go @@ -351,6 +351,7 @@ func (s *Server) setupRoutes(e *echo.Echo) { e.GET("/api/wallet", s.toEchoHandler(s.walletHandler.ListHandler)) e.POST("/api/wallet/:address/init", s.toEchoHandler(s.walletHandler.InitHandler)) e.DELETE("/api/wallet/:address", s.toEchoHandler(s.walletHandler.RemoveHandler)) + e.PATCH("/api/wallet/:address", s.toEchoHandler(s.walletHandler.UpdateHandler)) // Wallet Association e.POST("/api/preparation/:id/wallet/:wallet", s.toEchoHandler(s.walletHandler.AttachHandler)) diff --git a/client/swagger/http/wallet/update_wallet_parameters.go b/client/swagger/http/wallet/update_wallet_parameters.go new file mode 100644 index 00000000..9875d065 --- /dev/null +++ b/client/swagger/http/wallet/update_wallet_parameters.go @@ -0,0 +1,175 @@ +// Code generated by go-swagger; DO NOT EDIT. + +package wallet + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the swagger generate command + +import ( + "context" + "net/http" + "time" + + "github.com/go-openapi/errors" + "github.com/go-openapi/runtime" + cr "github.com/go-openapi/runtime/client" + "github.com/go-openapi/strfmt" + + "github.com/data-preservation-programs/singularity/client/swagger/models" +) + +// NewUpdateWalletParams creates a new UpdateWalletParams object, +// with the default timeout for this client. +// +// Default values are not hydrated, since defaults are normally applied by the API server side. +// +// To enforce default values in parameter, use SetDefaults or WithDefaults. +func NewUpdateWalletParams() *UpdateWalletParams { + return &UpdateWalletParams{ + timeout: cr.DefaultTimeout, + } +} + +// NewUpdateWalletParamsWithTimeout creates a new UpdateWalletParams object +// with the ability to set a timeout on a request. +func NewUpdateWalletParamsWithTimeout(timeout time.Duration) *UpdateWalletParams { + return &UpdateWalletParams{ + timeout: timeout, + } +} + +// NewUpdateWalletParamsWithContext creates a new UpdateWalletParams object +// with the ability to set a context for a request. +func NewUpdateWalletParamsWithContext(ctx context.Context) *UpdateWalletParams { + return &UpdateWalletParams{ + Context: ctx, + } +} + +// NewUpdateWalletParamsWithHTTPClient creates a new UpdateWalletParams object +// with the ability to set a custom HTTPClient for a request. +func NewUpdateWalletParamsWithHTTPClient(client *http.Client) *UpdateWalletParams { + return &UpdateWalletParams{ + HTTPClient: client, + } +} + +/* +UpdateWalletParams contains all the parameters to send to the API endpoint + + for the update wallet operation. + + Typically these are written to a http.Request. +*/ +type UpdateWalletParams struct { + + /* Address. + + Wallet address + */ + Address string + + /* Request. + + Request body + */ + Request *models.WalletUpdateRequest + + timeout time.Duration + Context context.Context + HTTPClient *http.Client +} + +// WithDefaults hydrates default values in the update wallet params (not the query body). +// +// All values with no default are reset to their zero value. +func (o *UpdateWalletParams) WithDefaults() *UpdateWalletParams { + o.SetDefaults() + return o +} + +// SetDefaults hydrates default values in the update wallet params (not the query body). +// +// All values with no default are reset to their zero value. +func (o *UpdateWalletParams) SetDefaults() { + // no default values defined for this parameter +} + +// WithTimeout adds the timeout to the update wallet params +func (o *UpdateWalletParams) WithTimeout(timeout time.Duration) *UpdateWalletParams { + o.SetTimeout(timeout) + return o +} + +// SetTimeout adds the timeout to the update wallet params +func (o *UpdateWalletParams) SetTimeout(timeout time.Duration) { + o.timeout = timeout +} + +// WithContext adds the context to the update wallet params +func (o *UpdateWalletParams) WithContext(ctx context.Context) *UpdateWalletParams { + o.SetContext(ctx) + return o +} + +// SetContext adds the context to the update wallet params +func (o *UpdateWalletParams) SetContext(ctx context.Context) { + o.Context = ctx +} + +// WithHTTPClient adds the HTTPClient to the update wallet params +func (o *UpdateWalletParams) WithHTTPClient(client *http.Client) *UpdateWalletParams { + o.SetHTTPClient(client) + return o +} + +// SetHTTPClient adds the HTTPClient to the update wallet params +func (o *UpdateWalletParams) SetHTTPClient(client *http.Client) { + o.HTTPClient = client +} + +// WithAddress adds the address to the update wallet params +func (o *UpdateWalletParams) WithAddress(address string) *UpdateWalletParams { + o.SetAddress(address) + return o +} + +// SetAddress adds the address to the update wallet params +func (o *UpdateWalletParams) SetAddress(address string) { + o.Address = address +} + +// WithRequest adds the request to the update wallet params +func (o *UpdateWalletParams) WithRequest(request *models.WalletUpdateRequest) *UpdateWalletParams { + o.SetRequest(request) + return o +} + +// SetRequest adds the request to the update wallet params +func (o *UpdateWalletParams) SetRequest(request *models.WalletUpdateRequest) { + o.Request = request +} + +// WriteToRequest writes these params to a swagger request +func (o *UpdateWalletParams) WriteToRequest(r runtime.ClientRequest, reg strfmt.Registry) error { + + if err := r.SetTimeout(o.timeout); err != nil { + return err + } + var res []error + + // path param address + if err := r.SetPathParam("address", o.Address); err != nil { + return err + } + if o.Request != nil { + if err := r.SetBodyParam(o.Request); err != nil { + return err + } + } + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} diff --git a/client/swagger/http/wallet/update_wallet_responses.go b/client/swagger/http/wallet/update_wallet_responses.go new file mode 100644 index 00000000..57cf35fd --- /dev/null +++ b/client/swagger/http/wallet/update_wallet_responses.go @@ -0,0 +1,334 @@ +// Code generated by go-swagger; DO NOT EDIT. + +package wallet + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the swagger generate command + +import ( + "encoding/json" + "fmt" + "io" + + "github.com/go-openapi/runtime" + "github.com/go-openapi/strfmt" + + "github.com/data-preservation-programs/singularity/client/swagger/models" +) + +// UpdateWalletReader is a Reader for the UpdateWallet structure. +type UpdateWalletReader struct { + formats strfmt.Registry +} + +// ReadResponse reads a server response into the received o. +func (o *UpdateWalletReader) ReadResponse(response runtime.ClientResponse, consumer runtime.Consumer) (interface{}, error) { + switch response.Code() { + case 200: + result := NewUpdateWalletOK() + if err := result.readResponse(response, consumer, o.formats); err != nil { + return nil, err + } + return result, nil + case 400: + result := NewUpdateWalletBadRequest() + if err := result.readResponse(response, consumer, o.formats); err != nil { + return nil, err + } + return nil, result + case 404: + result := NewUpdateWalletNotFound() + if err := result.readResponse(response, consumer, o.formats); err != nil { + return nil, err + } + return nil, result + case 500: + result := NewUpdateWalletInternalServerError() + if err := result.readResponse(response, consumer, o.formats); err != nil { + return nil, err + } + return nil, result + default: + return nil, runtime.NewAPIError("[PATCH /wallet/{address}/update] UpdateWallet", response, response.Code()) + } +} + +// NewUpdateWalletOK creates a UpdateWalletOK with default headers values +func NewUpdateWalletOK() *UpdateWalletOK { + return &UpdateWalletOK{} +} + +/* +UpdateWalletOK describes a response with status code 200, with default header values. + +OK +*/ +type UpdateWalletOK struct { + Payload *models.ModelWallet +} + +// IsSuccess returns true when this update wallet o k response has a 2xx status code +func (o *UpdateWalletOK) IsSuccess() bool { + return true +} + +// IsRedirect returns true when this update wallet o k response has a 3xx status code +func (o *UpdateWalletOK) IsRedirect() bool { + return false +} + +// IsClientError returns true when this update wallet o k response has a 4xx status code +func (o *UpdateWalletOK) IsClientError() bool { + return false +} + +// IsServerError returns true when this update wallet o k response has a 5xx status code +func (o *UpdateWalletOK) IsServerError() bool { + return false +} + +// IsCode returns true when this update wallet o k response a status code equal to that given +func (o *UpdateWalletOK) IsCode(code int) bool { + return code == 200 +} + +// Code gets the status code for the update wallet o k response +func (o *UpdateWalletOK) Code() int { + return 200 +} + +func (o *UpdateWalletOK) Error() string { + payload, _ := json.Marshal(o.Payload) + return fmt.Sprintf("[PATCH /wallet/{address}/update][%d] updateWalletOK %s", 200, payload) +} + +func (o *UpdateWalletOK) String() string { + payload, _ := json.Marshal(o.Payload) + return fmt.Sprintf("[PATCH /wallet/{address}/update][%d] updateWalletOK %s", 200, payload) +} + +func (o *UpdateWalletOK) GetPayload() *models.ModelWallet { + return o.Payload +} + +func (o *UpdateWalletOK) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error { + + o.Payload = new(models.ModelWallet) + + // response payload + if err := consumer.Consume(response.Body(), o.Payload); err != nil && err != io.EOF { + return err + } + + return nil +} + +// NewUpdateWalletBadRequest creates a UpdateWalletBadRequest with default headers values +func NewUpdateWalletBadRequest() *UpdateWalletBadRequest { + return &UpdateWalletBadRequest{} +} + +/* +UpdateWalletBadRequest describes a response with status code 400, with default header values. + +Bad Request +*/ +type UpdateWalletBadRequest struct { + Payload *models.APIHTTPError +} + +// IsSuccess returns true when this update wallet bad request response has a 2xx status code +func (o *UpdateWalletBadRequest) IsSuccess() bool { + return false +} + +// IsRedirect returns true when this update wallet bad request response has a 3xx status code +func (o *UpdateWalletBadRequest) IsRedirect() bool { + return false +} + +// IsClientError returns true when this update wallet bad request response has a 4xx status code +func (o *UpdateWalletBadRequest) IsClientError() bool { + return true +} + +// IsServerError returns true when this update wallet bad request response has a 5xx status code +func (o *UpdateWalletBadRequest) IsServerError() bool { + return false +} + +// IsCode returns true when this update wallet bad request response a status code equal to that given +func (o *UpdateWalletBadRequest) IsCode(code int) bool { + return code == 400 +} + +// Code gets the status code for the update wallet bad request response +func (o *UpdateWalletBadRequest) Code() int { + return 400 +} + +func (o *UpdateWalletBadRequest) Error() string { + payload, _ := json.Marshal(o.Payload) + return fmt.Sprintf("[PATCH /wallet/{address}/update][%d] updateWalletBadRequest %s", 400, payload) +} + +func (o *UpdateWalletBadRequest) String() string { + payload, _ := json.Marshal(o.Payload) + return fmt.Sprintf("[PATCH /wallet/{address}/update][%d] updateWalletBadRequest %s", 400, payload) +} + +func (o *UpdateWalletBadRequest) GetPayload() *models.APIHTTPError { + return o.Payload +} + +func (o *UpdateWalletBadRequest) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error { + + o.Payload = new(models.APIHTTPError) + + // response payload + if err := consumer.Consume(response.Body(), o.Payload); err != nil && err != io.EOF { + return err + } + + return nil +} + +// NewUpdateWalletNotFound creates a UpdateWalletNotFound with default headers values +func NewUpdateWalletNotFound() *UpdateWalletNotFound { + return &UpdateWalletNotFound{} +} + +/* +UpdateWalletNotFound describes a response with status code 404, with default header values. + +Not Found +*/ +type UpdateWalletNotFound struct { + Payload *models.APIHTTPError +} + +// IsSuccess returns true when this update wallet not found response has a 2xx status code +func (o *UpdateWalletNotFound) IsSuccess() bool { + return false +} + +// IsRedirect returns true when this update wallet not found response has a 3xx status code +func (o *UpdateWalletNotFound) IsRedirect() bool { + return false +} + +// IsClientError returns true when this update wallet not found response has a 4xx status code +func (o *UpdateWalletNotFound) IsClientError() bool { + return true +} + +// IsServerError returns true when this update wallet not found response has a 5xx status code +func (o *UpdateWalletNotFound) IsServerError() bool { + return false +} + +// IsCode returns true when this update wallet not found response a status code equal to that given +func (o *UpdateWalletNotFound) IsCode(code int) bool { + return code == 404 +} + +// Code gets the status code for the update wallet not found response +func (o *UpdateWalletNotFound) Code() int { + return 404 +} + +func (o *UpdateWalletNotFound) Error() string { + payload, _ := json.Marshal(o.Payload) + return fmt.Sprintf("[PATCH /wallet/{address}/update][%d] updateWalletNotFound %s", 404, payload) +} + +func (o *UpdateWalletNotFound) String() string { + payload, _ := json.Marshal(o.Payload) + return fmt.Sprintf("[PATCH /wallet/{address}/update][%d] updateWalletNotFound %s", 404, payload) +} + +func (o *UpdateWalletNotFound) GetPayload() *models.APIHTTPError { + return o.Payload +} + +func (o *UpdateWalletNotFound) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error { + + o.Payload = new(models.APIHTTPError) + + // response payload + if err := consumer.Consume(response.Body(), o.Payload); err != nil && err != io.EOF { + return err + } + + return nil +} + +// NewUpdateWalletInternalServerError creates a UpdateWalletInternalServerError with default headers values +func NewUpdateWalletInternalServerError() *UpdateWalletInternalServerError { + return &UpdateWalletInternalServerError{} +} + +/* +UpdateWalletInternalServerError describes a response with status code 500, with default header values. + +Internal Server Error +*/ +type UpdateWalletInternalServerError struct { + Payload *models.APIHTTPError +} + +// IsSuccess returns true when this update wallet internal server error response has a 2xx status code +func (o *UpdateWalletInternalServerError) IsSuccess() bool { + return false +} + +// IsRedirect returns true when this update wallet internal server error response has a 3xx status code +func (o *UpdateWalletInternalServerError) IsRedirect() bool { + return false +} + +// IsClientError returns true when this update wallet internal server error response has a 4xx status code +func (o *UpdateWalletInternalServerError) IsClientError() bool { + return false +} + +// IsServerError returns true when this update wallet internal server error response has a 5xx status code +func (o *UpdateWalletInternalServerError) IsServerError() bool { + return true +} + +// IsCode returns true when this update wallet internal server error response a status code equal to that given +func (o *UpdateWalletInternalServerError) IsCode(code int) bool { + return code == 500 +} + +// Code gets the status code for the update wallet internal server error response +func (o *UpdateWalletInternalServerError) Code() int { + return 500 +} + +func (o *UpdateWalletInternalServerError) Error() string { + payload, _ := json.Marshal(o.Payload) + return fmt.Sprintf("[PATCH /wallet/{address}/update][%d] updateWalletInternalServerError %s", 500, payload) +} + +func (o *UpdateWalletInternalServerError) String() string { + payload, _ := json.Marshal(o.Payload) + return fmt.Sprintf("[PATCH /wallet/{address}/update][%d] updateWalletInternalServerError %s", 500, payload) +} + +func (o *UpdateWalletInternalServerError) GetPayload() *models.APIHTTPError { + return o.Payload +} + +func (o *UpdateWalletInternalServerError) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error { + + o.Payload = new(models.APIHTTPError) + + // response payload + if err := consumer.Consume(response.Body(), o.Payload); err != nil && err != io.EOF { + return err + } + + return nil +} diff --git a/client/swagger/http/wallet/wallet_client.go b/client/swagger/http/wallet/wallet_client.go index e4f14425..3c5a425e 100644 --- a/client/swagger/http/wallet/wallet_client.go +++ b/client/swagger/http/wallet/wallet_client.go @@ -66,6 +66,8 @@ type ClientService interface { RemoveWallet(params *RemoveWalletParams, opts ...ClientOption) (*RemoveWalletNoContent, error) + UpdateWallet(params *UpdateWalletParams, opts ...ClientOption) (*UpdateWalletOK, error) + SetTransport(transport runtime.ClientTransport) } @@ -259,6 +261,44 @@ func (a *Client) RemoveWallet(params *RemoveWalletParams, opts ...ClientOption) panic(msg) } +/* +UpdateWallet updates wallet details +*/ +func (a *Client) UpdateWallet(params *UpdateWalletParams, opts ...ClientOption) (*UpdateWalletOK, error) { + // TODO: Validate the params before sending + if params == nil { + params = NewUpdateWalletParams() + } + op := &runtime.ClientOperation{ + ID: "UpdateWallet", + Method: "PATCH", + PathPattern: "/wallet/{address}/update", + ProducesMediaTypes: []string{"application/json"}, + ConsumesMediaTypes: []string{"application/json"}, + Schemes: []string{"http"}, + Params: params, + Reader: &UpdateWalletReader{formats: a.formats}, + Context: params.Context, + Client: params.HTTPClient, + } + for _, opt := range opts { + opt(op) + } + + result, err := a.transport.Submit(op) + if err != nil { + return nil, err + } + success, ok := result.(*UpdateWalletOK) + if ok { + return success, nil + } + // unexpected success response + // safeguard: normally, absent a default response, unknown success responses return an error above: so this is a codegen issue + msg := fmt.Sprintf("unexpected success response for UpdateWallet: API contract not enforced by server. Client expected to get an error, but got: %T", result) + panic(msg) +} + // SetTransport changes the transport on the client func (a *Client) SetTransport(transport runtime.ClientTransport) { a.transport = transport diff --git a/client/swagger/models/wallet_update_request.go b/client/swagger/models/wallet_update_request.go new file mode 100644 index 00000000..74c8d4ac --- /dev/null +++ b/client/swagger/models/wallet_update_request.go @@ -0,0 +1,56 @@ +// Code generated by go-swagger; DO NOT EDIT. + +package models + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the swagger generate command + +import ( + "context" + + "github.com/go-openapi/strfmt" + "github.com/go-openapi/swag" +) + +// WalletUpdateRequest wallet update request +// +// swagger:model wallet.UpdateRequest +type WalletUpdateRequest struct { + + // Name is readable label for the wallet + ActorName string `json:"actorName,omitempty"` + + // Contact is optional email for SP wallets + ContactInfo string `json:"contactInfo,omitempty"` + + // Location is optional region, country for SP wallets + Location string `json:"location,omitempty"` +} + +// Validate validates this wallet update request +func (m *WalletUpdateRequest) Validate(formats strfmt.Registry) error { + return nil +} + +// ContextValidate validates this wallet update request based on context it is used +func (m *WalletUpdateRequest) ContextValidate(ctx context.Context, formats strfmt.Registry) error { + return nil +} + +// MarshalBinary interface implementation +func (m *WalletUpdateRequest) MarshalBinary() ([]byte, error) { + if m == nil { + return nil, nil + } + return swag.WriteJSON(m) +} + +// UnmarshalBinary interface implementation +func (m *WalletUpdateRequest) UnmarshalBinary(b []byte) error { + var res WalletUpdateRequest + if err := swag.ReadJSON(b, &res); err != nil { + return err + } + *m = res + return nil +} diff --git a/cmd/app.go b/cmd/app.go index 806ac16b..b389c4ba 100644 --- a/cmd/app.go +++ b/cmd/app.go @@ -171,6 +171,7 @@ Upgrading: wallet.InitCmd, wallet.ListCmd, wallet.RemoveCmd, + wallet.UpdateCmd, }, }, { diff --git a/cmd/wallet/update.go b/cmd/wallet/update.go new file mode 100644 index 00000000..7ed94a35 --- /dev/null +++ b/cmd/wallet/update.go @@ -0,0 +1,88 @@ +package wallet + +import ( + "github.com/cockroachdb/errors" + "github.com/data-preservation-programs/singularity/cmd/cliutil" + "github.com/data-preservation-programs/singularity/database" + "github.com/data-preservation-programs/singularity/handler/wallet" + "github.com/gotidy/ptr" + "github.com/urfave/cli/v2" +) + +var UpdateCmd = &cli.Command{ + Name: "update", + Usage: "Update wallet details", + ArgsUsage: "
", + Description: `Update non-essential details of an existing wallet. + +This command allows you to update the following wallet properties: +- Name (optional wallet label) +- Contact information (email for SP) +- Location (region, country for SP) + +Essential properties like the wallet address, private key, and balance cannot be modified. + +EXAMPLES: + # Update the actor name + singularity wallet update f1abc123... --name "My Main Wallet" + + # Update multiple fields at once + singularity wallet update f1xyz789... --name "Storage Provider" --location "US-East"`, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "name", + Usage: "Set the readable label for the wallet", + }, + &cli.StringFlag{ + Name: "contact", + Usage: "Set the contact information (email) for the wallet", + }, + &cli.StringFlag{ + Name: "location", + Usage: "Set the location (region, country) for the wallet", + }, + }, + Before: cliutil.CheckNArgs, + Action: func(c *cli.Context) error { + address := c.Args().Get(0) + + db, closer, err := database.OpenFromCLI(c) + if err != nil { + return errors.WithStack(err) + } + defer closer.Close() + + // Build the update request + request := wallet.UpdateRequest{} + + if c.IsSet("name") { + request.Name = ptr.Of(c.String("name")) + } + + if c.IsSet("contact") { + request.Contact = ptr.Of(c.String("contact")) + } + + if c.IsSet("location") { + request.Location = ptr.Of(c.String("location")) + } + + // Check if at least one field is provided for update + if request.Name == nil && request.Contact == nil && request.Location == nil { + return errors.New("at least one field must be provided for update") + } + + w, err := wallet.Default.UpdateHandler( + c.Context, + db, + address, + request, + ) + if err != nil { + return errors.WithStack(err) + } + + cliutil.Print(c, w) + return nil + }, +} diff --git a/cmd/wallet_test.go b/cmd/wallet_test.go index 01fb884f..7ff2f7a2 100644 --- a/cmd/wallet_test.go +++ b/cmd/wallet_test.go @@ -137,3 +137,45 @@ func TestWalletRemove_NoReallyDoIt(t *testing.T) { require.ErrorIs(t, err, cliutil.ErrReallyDoIt) }) } + +func TestWalletUpdate(t *testing.T) { + testutil.OneWithoutReset(t, func(ctx context.Context, t *testing.T, db *gorm.DB) { + runner := NewRunner() + defer runner.Save(t) + mockHandler := new(wallet.MockWallet) + defer swapWalletHandler(mockHandler)() + mockHandler.On("UpdateHandler", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(&model.Wallet{ + ActorID: "id", + ActorName: "Updated Name", + Address: "address", + ContactInfo: "test@example.com", + Location: "US-East", + WalletType: model.SPWallet, + }, nil) + _, _, err := runner.Run(ctx, "singularity wallet update --name Updated --contact test@example.com --location US-East address") + require.NoError(t, err) + + _, _, err = runner.Run(ctx, "singularity --verbose wallet update --name Updated address") + require.NoError(t, err) + }) +} + +func TestWalletUpdate_NoAddress(t *testing.T) { + testutil.OneWithoutReset(t, func(ctx context.Context, t *testing.T, db *gorm.DB) { + runner := NewRunner() + defer runner.Save(t) + _, _, err := runner.Run(ctx, "singularity wallet update --name Test") + require.Error(t, err) + require.Contains(t, err.Error(), "incorrect number of arguments") + }) +} + +func TestWalletUpdate_NoFields(t *testing.T) { + testutil.OneWithoutReset(t, func(ctx context.Context, t *testing.T, db *gorm.DB) { + runner := NewRunner() + defer runner.Save(t) + _, _, err := runner.Run(ctx, "singularity wallet update address") + require.Error(t, err) + require.Contains(t, err.Error(), "at least one field must be provided for update") + }) +} diff --git a/docs/en/SUMMARY.md b/docs/en/SUMMARY.md index 706885da..7529c188 100644 --- a/docs/en/SUMMARY.md +++ b/docs/en/SUMMARY.md @@ -72,6 +72,7 @@ * [Init](cli-reference/wallet/init.md) * [List](cli-reference/wallet/list.md) * [Remove](cli-reference/wallet/remove.md) + * [Update](cli-reference/wallet/update.md) * [Storage](cli-reference/storage/README.md) * [Create](cli-reference/storage/create/README.md) * [Acd](cli-reference/storage/create/acd.md) diff --git a/docs/en/cli-reference/wallet/README.md b/docs/en/cli-reference/wallet/README.md index 883aa4e6..588a39ec 100644 --- a/docs/en/cli-reference/wallet/README.md +++ b/docs/en/cli-reference/wallet/README.md @@ -14,6 +14,7 @@ COMMANDS: init Initialize a wallet list List all imported wallets remove Remove a wallet + update Update wallet details help, h Shows a list of commands or help for one command OPTIONS: diff --git a/docs/en/cli-reference/wallet/update.md b/docs/en/cli-reference/wallet/update.md new file mode 100644 index 00000000..4076195a --- /dev/null +++ b/docs/en/cli-reference/wallet/update.md @@ -0,0 +1,34 @@ +# Update wallet details + +{% code fullWidth="true" %} +``` +NAME: + singularity wallet update - Update wallet details + +USAGE: + singularity wallet update [command options]
+ +DESCRIPTION: + Update non-essential details of an existing wallet. + + This command allows you to update the following wallet properties: + - Name (optional wallet label) + - Contact information (email for SP) + - Location (region, country for SP) + + Essential properties like the wallet address, private key, and balance cannot be modified. + + EXAMPLES: + # Update the actor name + singularity wallet update f1abc123... --name "My Main Wallet" + + # Update multiple fields at once + singularity wallet update f1xyz789... --name "Storage Provider" --location "US-East" + +OPTIONS: + --name value Set the readable label for the wallet + --contact value Set the contact information (email) for the wallet + --location value Set the location (region, country) for the wallet + --help, -h show help +``` +{% endcode %} diff --git a/docs/en/web-api-reference/wallet.md b/docs/en/web-api-reference/wallet.md index fc90ed4e..89cb92c2 100644 --- a/docs/en/web-api-reference/wallet.md +++ b/docs/en/web-api-reference/wallet.md @@ -20,3 +20,7 @@ [https://raw.githubusercontent.com/data-preservation-programs/singularity/main/docs/swagger/swagger.yaml](https://raw.githubusercontent.com/data-preservation-programs/singularity/main/docs/swagger/swagger.yaml) {% endswagger %} +{% swagger src="https://raw.githubusercontent.com/data-preservation-programs/singularity/main/docs/swagger/swagger.yaml" path="/wallet/{address}/update" method="patch" %} +[https://raw.githubusercontent.com/data-preservation-programs/singularity/main/docs/swagger/swagger.yaml](https://raw.githubusercontent.com/data-preservation-programs/singularity/main/docs/swagger/swagger.yaml) +{% endswagger %} + diff --git a/docs/swagger/docs.go b/docs/swagger/docs.go index 4c83f94a..f8f6c89c 100644 --- a/docs/swagger/docs.go +++ b/docs/swagger/docs.go @@ -5653,6 +5653,65 @@ const docTemplate = `{ } } } + }, + "/wallet/{address}/update": { + "patch": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Wallet" + ], + "summary": "Update wallet details", + "operationId": "UpdateWallet", + "parameters": [ + { + "type": "string", + "description": "Wallet address", + "name": "address", + "in": "path", + "required": true + }, + { + "description": "Request body", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/wallet.UpdateRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/model.Wallet" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.HTTPError" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.HTTPError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.HTTPError" + } + } + } + } } }, "definitions": { @@ -16551,6 +16610,23 @@ const docTemplate = `{ "type": "string" } } + }, + "wallet.UpdateRequest": { + "type": "object", + "properties": { + "actorName": { + "description": "Name is readable label for the wallet", + "type": "string" + }, + "contactInfo": { + "description": "Contact is optional email for SP wallets", + "type": "string" + }, + "location": { + "description": "Location is optional region, country for SP wallets", + "type": "string" + } + } } }, "externalDocs": { diff --git a/docs/swagger/swagger.json b/docs/swagger/swagger.json index ecc20a2a..a335acc2 100644 --- a/docs/swagger/swagger.json +++ b/docs/swagger/swagger.json @@ -5647,6 +5647,65 @@ } } } + }, + "/wallet/{address}/update": { + "patch": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Wallet" + ], + "summary": "Update wallet details", + "operationId": "UpdateWallet", + "parameters": [ + { + "type": "string", + "description": "Wallet address", + "name": "address", + "in": "path", + "required": true + }, + { + "description": "Request body", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/wallet.UpdateRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/model.Wallet" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.HTTPError" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.HTTPError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.HTTPError" + } + } + } + } } }, "definitions": { @@ -16545,6 +16604,23 @@ "type": "string" } } + }, + "wallet.UpdateRequest": { + "type": "object", + "properties": { + "actorName": { + "description": "Name is readable label for the wallet", + "type": "string" + }, + "contactInfo": { + "description": "Contact is optional email for SP wallets", + "type": "string" + }, + "location": { + "description": "Location is optional region, country for SP wallets", + "type": "string" + } + } } }, "externalDocs": { diff --git a/docs/swagger/swagger.yaml b/docs/swagger/swagger.yaml index 7c5eac39..e73b1e1a 100644 --- a/docs/swagger/swagger.yaml +++ b/docs/swagger/swagger.yaml @@ -8222,6 +8222,18 @@ definitions: description: This is the exported private key from lotus wallet export type: string type: object + wallet.UpdateRequest: + properties: + actorName: + description: Name is readable label for the wallet + type: string + contactInfo: + description: Contact is optional email for SP wallets + type: string + location: + description: Location is optional region, country for SP wallets + type: string + type: object externalDocs: description: OpenAPI url: https://swagger.io/resources/open-api/ @@ -11913,6 +11925,45 @@ paths: summary: Initialize a newly created wallet tags: - Wallet + /wallet/{address}/update: + patch: + consumes: + - application/json + operationId: UpdateWallet + parameters: + - description: Wallet address + in: path + name: address + required: true + type: string + - description: Request body + in: body + name: request + required: true + schema: + $ref: '#/definitions/wallet.UpdateRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/model.Wallet' + "400": + description: Bad Request + schema: + $ref: '#/definitions/api.HTTPError' + "404": + description: Not Found + schema: + $ref: '#/definitions/api.HTTPError' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.HTTPError' + summary: Update wallet details + tags: + - Wallet /wallet/create: post: consumes: diff --git a/go.mod b/go.mod index f5a80807..fc1ffe8c 100644 --- a/go.mod +++ b/go.mod @@ -86,13 +86,9 @@ require ( ) require ( - github.com/bitfield/gotestdox v0.2.2 // indirect - github.com/dnephin/pflag v1.0.7 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/shirou/gopsutil/v3 v3.23.3 // indirect - github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c // indirect - gotest.tools/gotestsum v1.12.2 // indirect ) require ( diff --git a/go.sum b/go.sum index 1608f837..8d929c15 100644 --- a/go.sum +++ b/go.sum @@ -97,8 +97,6 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY= github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0= -github.com/bitfield/gotestdox v0.2.2 h1:x6RcPAbBbErKLnapz1QeAlf3ospg8efBsedU93CDsnE= -github.com/bitfield/gotestdox v0.2.2/go.mod h1:D+gwtS0urjBrzguAkTM2wodsTQYFHdpx8eqRJ3N+9pY= github.com/bradfitz/go-smtpd v0.0.0-20170404230938-deb6d6237625/go.mod h1:HYsPBTaaSFSlLx/70C2HPIMNZpVV8+vt/A+FMnYP11g= github.com/brianvoe/gofakeit/v6 v6.23.2 h1:lVde18uhad5wII/f5RMVFLtdQNE0HaGFuBUXmYKk8i8= github.com/brianvoe/gofakeit/v6 v6.23.2/go.mod h1:Ow6qC71xtwm79anlwKRlWZW6zVq9D2XHE4QSSMP/rU8= @@ -164,8 +162,6 @@ github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0/go.mod h1:v57UDF4pDQJcEfFUCRop3 github.com/dlespiau/covertool v0.0.0-20180314162135-b0c4c6d0583a/go.mod h1:/eQMcW3eA1bzKx23ZYI2H3tXPdJB5JWYTHzoUPBvQY4= github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI= github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= -github.com/dnephin/pflag v1.0.7 h1:oxONGlWxhmUct0YzKTgrpQv9AUA1wtPBn7zuSjJqptk= -github.com/dnephin/pflag v1.0.7/go.mod h1:uxE91IoWURlOiTUIA8Mq5ZZkAv3dPUfZNaT80Zm7OQE= github.com/docker/distribution v2.8.2+incompatible h1:T3de5rq0dB1j30rp0sA2rER+m322EBzniBPB6ZIzuh8= github.com/docker/distribution v2.8.2+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= github.com/docker/docker v24.0.5+incompatible h1:WmgcE4fxyI6EEXxBRxsHnZXrO1pQ3smi0k/jho4HLeY= @@ -470,8 +466,6 @@ github.com/google/pprof v0.0.0-20250202011525-fc3143867406/go.mod h1:vavhavw2zAx github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/s2a-go v0.1.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o= github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw= -github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= -github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -1769,8 +1763,6 @@ gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8= gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo= gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= -gotest.tools/gotestsum v1.12.2 h1:eli4tu9Q2D/ogDsEGSr8XfQfl7mT0JsGOG6DFtUiZ/Q= -gotest.tools/gotestsum v1.12.2/go.mod h1:kjRtCglPZVsSU0hFHX3M5VWBM6Y63emHuB14ER1/sow= gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= grpc.go4.org v0.0.0-20170609214715-11d0a25b4919/go.mod h1:77eQGdRu53HpSqPFJFmuJdjuHRquDANNeA4x7B8WQ9o= diff --git a/handler/wallet/interface.go b/handler/wallet/interface.go index f0cb0305..9d50ff7c 100644 --- a/handler/wallet/interface.go +++ b/handler/wallet/interface.go @@ -54,6 +54,12 @@ type Handler interface { db *gorm.DB, address string, ) error + UpdateHandler( + ctx context.Context, + db *gorm.DB, + address string, + request UpdateRequest, + ) (*model.Wallet, error) } type DefaultHandler struct{} @@ -105,3 +111,8 @@ func (m *MockWallet) RemoveHandler(ctx context.Context, db *gorm.DB, address str args := m.Called(ctx, db, address) return args.Error(0) } + +func (m *MockWallet) UpdateHandler(ctx context.Context, db *gorm.DB, address string, request UpdateRequest) (*model.Wallet, error) { + args := m.Called(ctx, db, address, request) + return args.Get(0).(*model.Wallet), args.Error(1) +} diff --git a/handler/wallet/update.go b/handler/wallet/update.go new file mode 100644 index 00000000..776cbbb9 --- /dev/null +++ b/handler/wallet/update.go @@ -0,0 +1,101 @@ +package wallet + +import ( + "context" + + "github.com/cockroachdb/errors" + "github.com/data-preservation-programs/singularity/database" + "github.com/data-preservation-programs/singularity/handler/handlererror" + "github.com/data-preservation-programs/singularity/model" + "gorm.io/gorm" +) + +type UpdateRequest struct { + Name *string `json:"actorName,omitempty"` // Name is readable label for the wallet + Contact *string `json:"contactInfo,omitempty"` // Contact is optional email for SP wallets + Location *string `json:"location,omitempty"` // Location is optional region, country for SP wallets +} + +// @ID UpdateWallet +// @Summary Update wallet details +// @Tags Wallet +// @Accept json +// @Produce json +// @Param address path string true "Wallet address" +// @Param request body UpdateRequest true "Request body" +// @Success 200 {object} model.Wallet +// @Failure 400 {object} api.HTTPError +// @Failure 404 {object} api.HTTPError +// @Failure 500 {object} api.HTTPError +// @Router /wallet/{address}/update [patch] +func _() {} + +// UpdateHandler updates non-essential details of an existing wallet. +// Only fields provided in the request will be updated (partial update). +// Essential fields like Address, PrivateKey, and Balance cannot be modified. +// +// Parameters: +// - ctx: The context for database transactions and other operations. +// - db: A pointer to the gorm.DB instance representing the database connection. +// - address: The wallet address to update. +// - request: UpdateRequest containing the fields to update. +// +// Returns: +// - A pointer to the updated Wallet model if successful. +// - An error, if any occurred during the database operation. +func (DefaultHandler) UpdateHandler( + ctx context.Context, + db *gorm.DB, + address string, + request UpdateRequest, +) (*model.Wallet, error) { + db = db.WithContext(ctx) + + // Find the existing wallet + var wallet model.Wallet + err := wallet.FindByIDOrAddr(db, address) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.Wrap(handlererror.ErrNotFound, "wallet not found") + } + if err != nil { + return nil, errors.WithStack(err) + } + + // Prepare update data - only include fields that are provided + updates := make(map[string]interface{}) + + if request.Name != nil { + updates["actor_name"] = *request.Name + } + + if request.Contact != nil { + updates["contact_info"] = *request.Contact + } + + if request.Location != nil { + updates["location"] = *request.Location + } + + // If no fields to update, return the existing wallet + if len(updates) == 0 { + return &wallet, nil + } + + // Perform the update + err = database.DoRetry(ctx, func() error { + return db.Model(&wallet).Updates(updates).Error + }) + if err != nil { + return nil, errors.WithStack(err) + } + + // Fetch the updated wallet to return + err = database.DoRetry(ctx, func() error { + return db.Where("address = ?", address).First(&wallet).Error + }) + if err != nil { + return nil, errors.WithStack(err) + } + + return &wallet, nil +} diff --git a/handler/wallet/update_test.go b/handler/wallet/update_test.go new file mode 100644 index 00000000..a1c0e08e --- /dev/null +++ b/handler/wallet/update_test.go @@ -0,0 +1,108 @@ +package wallet + +import ( + "context" + "testing" + + "github.com/data-preservation-programs/singularity/model" + "github.com/data-preservation-programs/singularity/util/testutil" + "github.com/gotidy/ptr" + "github.com/stretchr/testify/require" + "gorm.io/gorm" +) + +func TestDefaultHandler_UpdateHandler(t *testing.T) { + testutil.All(t, func(ctx context.Context, t *testing.T, db *gorm.DB) { + // Create a test wallet first + wallet := model.Wallet{ + ActorID: "test-id", + ActorName: "Original Name", + Address: "f1test123address", + ContactInfo: "original@example.com", + Location: "Original Location", + WalletType: model.SPWallet, + } + err := db.Create(&wallet).Error + require.NoError(t, err) + + handler := DefaultHandler{} + + t.Run("update all fields", func(t *testing.T) { + newActorName := "Updated Name" + newContactInfo := "updated@example.com" + newLocation := "Updated Location" + + request := UpdateRequest{ + Name: &newActorName, + Contact: &newContactInfo, + Location: &newLocation, + } + + updated, err := handler.UpdateHandler(ctx, db, wallet.Address, request) + require.NoError(t, err) + require.NotNil(t, updated) + + require.Equal(t, newActorName, updated.ActorName) + require.Equal(t, newContactInfo, updated.ContactInfo) + require.Equal(t, newLocation, updated.Location) + + // Verify essential fields are unchanged + require.Equal(t, wallet.Address, updated.Address) + require.Equal(t, wallet.PrivateKey, updated.PrivateKey) + require.Equal(t, wallet.ActorID, updated.ActorID) + require.Equal(t, wallet.WalletType, updated.WalletType) + }) + + t.Run("update single field", func(t *testing.T) { + newActorName := "Single Field Update" + request := UpdateRequest{ + Name: &newActorName, + } + + updated, err := handler.UpdateHandler(ctx, db, wallet.Address, request) + require.NoError(t, err) + require.NotNil(t, updated) + + require.Equal(t, newActorName, updated.ActorName) + // Other fields should remain as they were from the previous update + require.Equal(t, "updated@example.com", updated.ContactInfo) + require.Equal(t, "Updated Location", updated.Location) + require.Equal(t, model.SPWallet, updated.WalletType) + }) + + t.Run("update with empty string", func(t *testing.T) { + emptyString := "" + request := UpdateRequest{ + Contact: &emptyString, + } + + updated, err := handler.UpdateHandler(ctx, db, wallet.Address, request) + require.NoError(t, err) + require.NotNil(t, updated) + + require.Equal(t, "", updated.ContactInfo) + }) + + t.Run("no fields to update", func(t *testing.T) { + request := UpdateRequest{} + + updated, err := handler.UpdateHandler(ctx, db, wallet.Address, request) + require.NoError(t, err) + require.NotNil(t, updated) + + // Should return the existing wallet unchanged + require.Equal(t, "Single Field Update", updated.ActorName) + }) + + t.Run("wallet not found", func(t *testing.T) { + request := UpdateRequest{ + Name: ptr.Of("Test"), + } + + updated, err := handler.UpdateHandler(ctx, db, "nonexistent-address", request) + require.Error(t, err) + require.Nil(t, updated) + require.Contains(t, err.Error(), "wallet not found") + }) + }) +} From 52a3576b3aa03acd38c7f581f2d34afb2aacc4a7 Mon Sep 17 00:00:00 2001 From: Zach Fedor Date: Wed, 18 Jun 2025 15:21:52 -0400 Subject: [PATCH 4/4] feat: add SPWallet fields to create wallet handler Allows creating wallets of SPWallet type from create handler. This will validate the provided Address and ActorID to prevent errors. Also allows inserting optional details like name, contact info, and location on creation rather than requiring a separate update. --- .../swagger/models/wallet_create_request.go | 17 +++- cmd/wallet/create.go | 75 +++++++++++---- docs/en/cli-reference/wallet/create.md | 24 +++-- docs/swagger/docs.go | 19 +++- docs/swagger/swagger.json | 19 +++- docs/swagger/swagger.yaml | 14 ++- go.mod | 4 + go.sum | 8 ++ handler/wallet/create.go | 93 ++++++++++++++++--- handler/wallet/create_test.go | 93 ++++++++++++++++++- handler/wallet/interface.go | 3 +- util/testutil/testutils.go | 2 + 12 files changed, 326 insertions(+), 45 deletions(-) diff --git a/client/swagger/models/wallet_create_request.go b/client/swagger/models/wallet_create_request.go index a1411264..f3dfd47f 100644 --- a/client/swagger/models/wallet_create_request.go +++ b/client/swagger/models/wallet_create_request.go @@ -17,8 +17,23 @@ import ( // swagger:model wallet.CreateRequest type WalletCreateRequest struct { - // This is either "secp256k1" or "bls" + // actor Id + ActorID string `json:"actorId,omitempty"` + + // For SPWallet creation + Address string `json:"address,omitempty"` + + // contact + Contact string `json:"contact,omitempty"` + + // For UserWallet creation (generates new keypair) KeyType string `json:"keyType,omitempty"` + + // location + Location string `json:"location,omitempty"` + + // Optional fields for adding details to Wallet + Name string `json:"name,omitempty"` } // Validate validates this wallet create request diff --git a/cmd/wallet/create.go b/cmd/wallet/create.go index 6c08f37c..0ce85bf4 100644 --- a/cmd/wallet/create.go +++ b/cmd/wallet/create.go @@ -5,6 +5,7 @@ import ( "github.com/data-preservation-programs/singularity/cmd/cliutil" "github.com/data-preservation-programs/singularity/database" "github.com/data-preservation-programs/singularity/handler/wallet" + "github.com/data-preservation-programs/singularity/util" "github.com/urfave/cli/v2" ) @@ -12,26 +13,52 @@ var CreateCmd = &cli.Command{ Name: "create", Usage: "Create a new wallet", ArgsUsage: "[type]", - Description: `Create a new Filecoin wallet using offline keypair generation. + Description: `Create a new Filecoin wallet or storage provider contact entry. -The wallet will be stored locally in the Singularity database and can be used for making deals and other operations. The private key is generated securely and stored encrypted. +The command automatically detects the wallet type based on provided arguments: +- For UserWallet: Creates a wallet with offline keypair generation +- For SPWallet: Creates a contact entry for a storage provider -SUPPORTED KEY TYPES: +SUPPORTED KEY TYPES (for UserWallet): secp256k1 ECDSA using the secp256k1 curve (default, most common) bls BLS signature scheme (Boneh-Lynn-Shacham) EXAMPLES: - # Create a secp256k1 wallet (default) + # Create a secp256k1 UserWallet (default) singularity wallet create - # Create a secp256k1 wallet explicitly + # Create a secp256k1 UserWallet explicitly singularity wallet create secp256k1 - # Create a BLS wallet + # Create a BLS UserWallet singularity wallet create bls + # Create an SPWallet contact entry + singularity wallet create --address f3abc123... --actor-id f01234 --name "Example SP" + The newly created wallet address and other details will be displayed upon successful creation.`, - Before: cliutil.CheckNArgs, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "address", + Usage: "Storage provider wallet address (creates SPWallet contact)", + }, + &cli.StringFlag{ + Name: "actor-id", + Usage: "Storage provider actor ID (e.g., f01234)", + }, + &cli.StringFlag{ + Name: "name", + Usage: "Optional display name", + }, + &cli.StringFlag{ + Name: "contact", + Usage: "Optional contact information", + }, + &cli.StringFlag{ + Name: "location", + Usage: "Optional provider location", + }, + }, Action: func(c *cli.Context) error { db, closer, err := database.OpenFromCLI(c) if err != nil { @@ -39,18 +66,32 @@ The newly created wallet address and other details will be displayed upon succes } defer closer.Close() - // Default to secp256k1 if no type is provided - keyType := c.Args().Get(0) - if keyType == "" { - keyType = wallet.KTSecp256k1.String() + request := wallet.CreateRequest{ + Name: c.String("name"), + Contact: c.String("contact"), + Location: c.String("location"), + } + + // Check if this is an SPWallet creation (has address or actor-id) + address := c.String("address") + actorID := c.String("actor-id") + + if address != "" || actorID != "" { + // Create SPWallet contact entry + request.Address = address + request.ActorID = actorID + } else { + // Create UserWallet with keypair generation + keyType := c.Args().Get(0) + if keyType == "" { + keyType = wallet.KTSecp256k1.String() + } + request.KeyType = keyType } - w, err := wallet.Default.CreateHandler( - c.Context, - db, - wallet.CreateRequest{ - KeyType: keyType, - }) + lotusClient := util.NewLotusClient(c.String("lotus-api"), c.String("lotus-token")) + + w, err := wallet.Default.CreateHandler(c.Context, db, lotusClient, request) if err != nil { return errors.WithStack(err) } diff --git a/docs/en/cli-reference/wallet/create.md b/docs/en/cli-reference/wallet/create.md index 743ffadd..dad33296 100644 --- a/docs/en/cli-reference/wallet/create.md +++ b/docs/en/cli-reference/wallet/create.md @@ -9,27 +9,37 @@ USAGE: singularity wallet create [command options] [type] DESCRIPTION: - Create a new Filecoin wallet using offline keypair generation. + Create a new Filecoin wallet or storage provider contact entry. - The wallet will be stored locally in the Singularity database and can be used for making deals and other operations. The private key is generated securely and stored encrypted. + The command automatically detects the wallet type based on provided arguments: + - For UserWallet: Creates a wallet with offline keypair generation + - For SPWallet: Creates a contact entry for a storage provider - SUPPORTED KEY TYPES: + SUPPORTED KEY TYPES (for UserWallet): secp256k1 ECDSA using the secp256k1 curve (default, most common) bls BLS signature scheme (Boneh-Lynn-Shacham) EXAMPLES: - # Create a secp256k1 wallet (default) + # Create a secp256k1 UserWallet (default) singularity wallet create - # Create a secp256k1 wallet explicitly + # Create a secp256k1 UserWallet explicitly singularity wallet create secp256k1 - # Create a BLS wallet + # Create a BLS UserWallet singularity wallet create bls + # Create an SPWallet contact entry + singularity wallet create --address f3abc123... --actor-id f01234 --name "Example SP" + The newly created wallet address and other details will be displayed upon successful creation. OPTIONS: - --help, -h show help + --address value Storage provider wallet address (creates SPWallet contact) + --actor-id value Storage provider actor ID (e.g., f01234) + --name value Optional display name + --contact value Optional contact information + --location value Optional provider location + --help, -h show help ``` {% endcode %} diff --git a/docs/swagger/docs.go b/docs/swagger/docs.go index f8f6c89c..949eba63 100644 --- a/docs/swagger/docs.go +++ b/docs/swagger/docs.go @@ -16596,8 +16596,25 @@ const docTemplate = `{ "wallet.CreateRequest": { "type": "object", "properties": { + "actorId": { + "type": "string" + }, + "address": { + "description": "For SPWallet creation", + "type": "string" + }, + "contact": { + "type": "string" + }, "keyType": { - "description": "This is either \"secp256k1\" or \"bls\"", + "description": "For UserWallet creation (generates new keypair)", + "type": "string" + }, + "location": { + "type": "string" + }, + "name": { + "description": "Optional fields for adding details to Wallet", "type": "string" } } diff --git a/docs/swagger/swagger.json b/docs/swagger/swagger.json index a335acc2..ee06c39f 100644 --- a/docs/swagger/swagger.json +++ b/docs/swagger/swagger.json @@ -16590,8 +16590,25 @@ "wallet.CreateRequest": { "type": "object", "properties": { + "actorId": { + "type": "string" + }, + "address": { + "description": "For SPWallet creation", + "type": "string" + }, + "contact": { + "type": "string" + }, "keyType": { - "description": "This is either \"secp256k1\" or \"bls\"", + "description": "For UserWallet creation (generates new keypair)", + "type": "string" + }, + "location": { + "type": "string" + }, + "name": { + "description": "Optional fields for adding details to Wallet", "type": "string" } } diff --git a/docs/swagger/swagger.yaml b/docs/swagger/swagger.yaml index e73b1e1a..56d99cec 100644 --- a/docs/swagger/swagger.yaml +++ b/docs/swagger/swagger.yaml @@ -8212,8 +8212,20 @@ definitions: type: object wallet.CreateRequest: properties: + actorId: + type: string + address: + description: For SPWallet creation + type: string + contact: + type: string keyType: - description: This is either "secp256k1" or "bls" + description: For UserWallet creation (generates new keypair) + type: string + location: + type: string + name: + description: Optional fields for adding details to Wallet type: string type: object wallet.ImportRequest: diff --git a/go.mod b/go.mod index fc1ffe8c..13d0a4d0 100644 --- a/go.mod +++ b/go.mod @@ -86,9 +86,13 @@ require ( ) require ( + github.com/bitfield/gotestdox v0.2.2 // indirect + github.com/dnephin/pflag v1.0.7 // indirect github.com/google/go-cmp v0.7.0 // indirect + github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect github.com/shirou/gopsutil/v3 v3.23.3 // indirect golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c // indirect + gotest.tools/gotestsum v1.12.2 // indirect ) require ( diff --git a/go.sum b/go.sum index 8d929c15..1608f837 100644 --- a/go.sum +++ b/go.sum @@ -97,6 +97,8 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY= github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0= +github.com/bitfield/gotestdox v0.2.2 h1:x6RcPAbBbErKLnapz1QeAlf3ospg8efBsedU93CDsnE= +github.com/bitfield/gotestdox v0.2.2/go.mod h1:D+gwtS0urjBrzguAkTM2wodsTQYFHdpx8eqRJ3N+9pY= github.com/bradfitz/go-smtpd v0.0.0-20170404230938-deb6d6237625/go.mod h1:HYsPBTaaSFSlLx/70C2HPIMNZpVV8+vt/A+FMnYP11g= github.com/brianvoe/gofakeit/v6 v6.23.2 h1:lVde18uhad5wII/f5RMVFLtdQNE0HaGFuBUXmYKk8i8= github.com/brianvoe/gofakeit/v6 v6.23.2/go.mod h1:Ow6qC71xtwm79anlwKRlWZW6zVq9D2XHE4QSSMP/rU8= @@ -162,6 +164,8 @@ github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0/go.mod h1:v57UDF4pDQJcEfFUCRop3 github.com/dlespiau/covertool v0.0.0-20180314162135-b0c4c6d0583a/go.mod h1:/eQMcW3eA1bzKx23ZYI2H3tXPdJB5JWYTHzoUPBvQY4= github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI= github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= +github.com/dnephin/pflag v1.0.7 h1:oxONGlWxhmUct0YzKTgrpQv9AUA1wtPBn7zuSjJqptk= +github.com/dnephin/pflag v1.0.7/go.mod h1:uxE91IoWURlOiTUIA8Mq5ZZkAv3dPUfZNaT80Zm7OQE= github.com/docker/distribution v2.8.2+incompatible h1:T3de5rq0dB1j30rp0sA2rER+m322EBzniBPB6ZIzuh8= github.com/docker/distribution v2.8.2+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= github.com/docker/docker v24.0.5+incompatible h1:WmgcE4fxyI6EEXxBRxsHnZXrO1pQ3smi0k/jho4HLeY= @@ -466,6 +470,8 @@ github.com/google/pprof v0.0.0-20250202011525-fc3143867406/go.mod h1:vavhavw2zAx github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/s2a-go v0.1.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o= github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -1763,6 +1769,8 @@ gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8= gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo= gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= +gotest.tools/gotestsum v1.12.2 h1:eli4tu9Q2D/ogDsEGSr8XfQfl7mT0JsGOG6DFtUiZ/Q= +gotest.tools/gotestsum v1.12.2/go.mod h1:kjRtCglPZVsSU0hFHX3M5VWBM6Y63emHuB14ER1/sow= gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= grpc.go4.org v0.0.0-20170609214715-11d0a25b4919/go.mod h1:77eQGdRu53HpSqPFJFmuJdjuHRquDANNeA4x7B8WQ9o= diff --git a/handler/wallet/create.go b/handler/wallet/create.go index 3bd7f118..f7c5a0eb 100644 --- a/handler/wallet/create.go +++ b/handler/wallet/create.go @@ -15,6 +15,7 @@ import ( "github.com/filecoin-project/go-address" "github.com/filecoin-project/go-crypto" g1 "github.com/phoreproject/bls/g1pubs" + "github.com/ybbus/jsonrpc/v3" "golang.org/x/xerrors" "gorm.io/gorm" ) @@ -99,7 +100,17 @@ func GenerateKey(keyType string) (string, string, error) { } type CreateRequest struct { - KeyType string `json:"keyType"` // This is either "secp256k1" or "bls" + // For UserWallet creation (generates new keypair) + KeyType string `json:"keyType,omitempty"` // This is either "secp256k1" or "bls" + + // For SPWallet creation + Address string `json:"address,omitempty"` + ActorID string `json:"actorId,omitempty"` + + // Optional fields for adding details to Wallet + Name string `json:"name,omitempty"` + Contact string `json:"contact,omitempty"` + Location string `json:"location,omitempty"` } // @ID CreateWallet @@ -114,33 +125,93 @@ type CreateRequest struct { // @Router /wallet/create [post] func _() {} -// CreateHandler creates a new wallet using offline keypair generation and a new record in the local database. +// CreateHandler creates a new wallet and stores it in the local database. +// The wallet type is automatically inferred from the provided parameters: +// - If KeyType is provided: creates a UserWallet with generated keypair +// - If Address is provided: creates an SPWallet contact entry // // Parameters: // - ctx: The context for database transactions and other operations. // - db: A pointer to the gorm.DB instance representing the database connection. +// - lotusClient: The RPC client used to interact with a Lotus node for actor lookup (only used for SP wallets). +// - request: CreateRequest with either KeyType (for UserWallet) or Address (for SPWallet) // // Returns: // - A pointer to the created Wallet model if successful. -// - An error, if any occurred during the database insert operation. +// - An error, if any occurred during validation or database operations. func (DefaultHandler) CreateHandler( ctx context.Context, db *gorm.DB, + lotusClient jsonrpc.RPCClient, request CreateRequest, ) (*model.Wallet, error) { db = db.WithContext(ctx) - // Generate a new keypair - privateKey, address, err := GenerateKey(request.KeyType) - if err != nil { - return nil, errors.WithStack(err) + // Infer wallet type from provided parameters + hasKeyType := request.KeyType != "" + hasAddress := request.Address != "" + hasActorID := request.ActorID != "" + + // Validate that only one wallet type is specified + switch { + case !hasKeyType && !hasAddress && !hasActorID: + return nil, errors.New("must specify either KeyType (for UserWallet) or Address/ActorID (for SPWallet)") + case !hasKeyType && !(hasAddress && hasActorID): + return nil, errors.New("must specify both Address and ActorID (for SPWallet)") + case hasKeyType && (hasAddress || hasActorID): + return nil, errors.New("cannot specify both KeyType (for UserWallet) and Address/ActorID (for SPWallet) - please specify parameters for one wallet type") } - wallet := model.Wallet{ - Address: address, - PrivateKey: privateKey, + var wallet model.Wallet + + if hasKeyType { + // Create UserWallet: generate a new keypair + privateKey, address, err := GenerateKey(request.KeyType) + if err != nil { + return nil, errors.WithStack(err) + } + + wallet = model.Wallet{ + Address: address, + PrivateKey: privateKey, + WalletType: model.UserWallet, + // ActorID is empty for UserWallets until initialized + } + } else { + // Validate the address and actor ID with Lotus + addr, err := address.NewFromString(request.Address) + if err != nil { + return nil, errors.Wrap(handlererror.ErrInvalidParameter, "invalid address format") + } + + var result string + err = lotusClient.CallFor(ctx, &result, "Filecoin.StateLookupID", addr.String(), nil) + if err != nil { + logger.Errorw("failed to lookup state for wallet address", "addr", addr, "err", err) + return nil, errors.Join(handlererror.ErrInvalidParameter, errors.Wrap(err, "failed to lookup actor ID")) + } + + _, err = address.NewFromString(result) + if err != nil { + return nil, errors.Wrap(handlererror.ErrInvalidParameter, "invalid actor ID") + } else if result != request.ActorID { + return nil, errors.Wrap(handlererror.ErrInvalidParameter, "provided actor ID is not associated with address") + } + + wallet = model.Wallet{ + ActorID: result, + Address: result[:1] + addr.String()[1:], + WalletType: model.SPWallet, + // PrivateKey is empty for SP wallets + } } - err = database.DoRetry(ctx, func() error { + + // Update wallet details + wallet.ActorName = request.Name + wallet.ContactInfo = request.Contact + wallet.Location = request.Location + + err := database.DoRetry(ctx, func() error { return db.Create(&wallet).Error }) if util.IsDuplicateKeyError(err) { diff --git a/handler/wallet/create_test.go b/handler/wallet/create_test.go index a966c48f..995b9126 100644 --- a/handler/wallet/create_test.go +++ b/handler/wallet/create_test.go @@ -4,6 +4,7 @@ import ( "context" "testing" + "github.com/data-preservation-programs/singularity/util" "github.com/data-preservation-programs/singularity/util/testutil" "github.com/stretchr/testify/require" "gorm.io/gorm" @@ -11,25 +12,107 @@ import ( func TestCreateHandler(t *testing.T) { testutil.All(t, func(ctx context.Context, t *testing.T, db *gorm.DB) { - t.Run("success-secp256k1", func(t *testing.T) { - w, err := Default.CreateHandler(ctx, db, CreateRequest{KeyType: KTSecp256k1.String()}) + lotusClient := util.NewLotusClient("https://api.node.glif.io/rpc/v0", "") + + t.Run("success-user-wallet-secp256k1", func(t *testing.T) { + w, err := Default.CreateHandler(ctx, db, lotusClient, CreateRequest{ + KeyType: KTSecp256k1.String(), + }) require.NoError(t, err) require.NotEmpty(t, w.Address) require.Equal(t, "f1", w.Address[:2]) require.NotEmpty(t, w.PrivateKey) + require.Equal(t, "UserWallet", string(w.WalletType)) }) - t.Run("success-bls", func(t *testing.T) { - w, err := Default.CreateHandler(ctx, db, CreateRequest{KeyType: KTBLS.String()}) + t.Run("success-user-wallet-bls", func(t *testing.T) { + w, err := Default.CreateHandler(ctx, db, lotusClient, CreateRequest{ + KeyType: KTBLS.String(), + }) require.NoError(t, err) require.NotEmpty(t, w.Address) require.Equal(t, "f3", w.Address[:2]) require.NotEmpty(t, w.PrivateKey) + require.Equal(t, "UserWallet", string(w.WalletType)) + }) + + t.Run("success-user-wallet-with-details", func(t *testing.T) { + w, err := Default.CreateHandler(ctx, db, lotusClient, CreateRequest{ + KeyType: KTSecp256k1.String(), + Name: "my wallet", + }) + require.NoError(t, err) + require.NotEmpty(t, w.Address) + require.Equal(t, "f1", w.Address[:2]) + require.NotEmpty(t, w.PrivateKey) + require.Equal(t, "UserWallet", string(w.WalletType)) + require.Equal(t, "my wallet", w.ActorName) + }) + t.Run("success-sp-wallet", func(t *testing.T) { + w, err := Default.CreateHandler(ctx, db, lotusClient, CreateRequest{ + Address: testutil.TestWalletAddr, + ActorID: testutil.TestWalletActorID, + Name: "Test SP", + Contact: "test@example.com", + Location: "US", + }) + require.NoError(t, err) + require.Equal(t, testutil.TestWalletAddr, w.Address) + require.Equal(t, testutil.TestWalletActorID, w.ActorID) + require.Equal(t, "Test SP", w.ActorName) + require.Equal(t, "test@example.com", w.ContactInfo) + require.Equal(t, "US", w.Location) + require.Empty(t, w.PrivateKey) + require.Equal(t, "SPWallet", string(w.WalletType)) + }) + + t.Run("error-no-parameters", func(t *testing.T) { + _, err := Default.CreateHandler(ctx, db, lotusClient, CreateRequest{}) + require.Error(t, err) + require.Contains(t, err.Error(), "must specify either KeyType (for UserWallet) or Address/ActorID (for SPWallet)") + }) + + t.Run("error-sp-wallet-missing-actorid", func(t *testing.T) { + _, err := Default.CreateHandler(ctx, db, lotusClient, CreateRequest{ + Address: "f123456789", + Name: "Test SP", + }) + require.Error(t, err) + require.Contains(t, err.Error(), "must specify both Address and ActorID (for SPWallet)") + }) + + t.Run("error-sp-wallet-missing-address", func(t *testing.T) { + _, err := Default.CreateHandler(ctx, db, lotusClient, CreateRequest{ + ActorID: "f1234", + Name: "Test SP", + }) + require.Error(t, err) + require.Contains(t, err.Error(), "must specify both Address and ActorID (for SPWallet)") + }) + + t.Run("error-sp-wallet-mismatched-id", func(t *testing.T) { + _, err := Default.CreateHandler(ctx, db, lotusClient, CreateRequest{ + Address: testutil.TestWalletAddr, + ActorID: "wrong-actor-id", + }) + require.Error(t, err) + require.Contains(t, err.Error(), "provided actor ID is not associated with address") }) t.Run("invalid-key-type", func(t *testing.T) { - _, err := Default.CreateHandler(ctx, db, CreateRequest{KeyType: "invalid-type"}) + _, err := Default.CreateHandler(ctx, db, lotusClient, CreateRequest{ + KeyType: "invalid-type", + }) + require.Error(t, err) + }) + + t.Run("error-mixed-parameters", func(t *testing.T) { + _, err := Default.CreateHandler(ctx, db, lotusClient, CreateRequest{ + KeyType: KTSecp256k1.String(), + Address: "f3abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", + }) require.Error(t, err) + require.Contains(t, err.Error(), "cannot specify both KeyType (for UserWallet) and Address/ActorID (for SPWallet)") }) }) } diff --git a/handler/wallet/interface.go b/handler/wallet/interface.go index 9d50ff7c..bc8f6329 100644 --- a/handler/wallet/interface.go +++ b/handler/wallet/interface.go @@ -20,6 +20,7 @@ type Handler interface { CreateHandler( ctx context.Context, db *gorm.DB, + lotusClient jsonrpc.RPCClient, request CreateRequest, ) (*model.Wallet, error) DetachHandler( @@ -77,7 +78,7 @@ func (m *MockWallet) AttachHandler(ctx context.Context, db *gorm.DB, preparation return args.Get(0).(*model.Preparation), args.Error(1) } -func (m *MockWallet) CreateHandler(ctx context.Context, db *gorm.DB, request CreateRequest) (*model.Wallet, error) { +func (m *MockWallet) CreateHandler(ctx context.Context, db *gorm.DB, lotusClient jsonrpc.RPCClient, request CreateRequest) (*model.Wallet, error) { args := m.Called(ctx, db, request) return args.Get(0).(*model.Wallet), args.Error(1) } diff --git a/util/testutil/testutils.go b/util/testutil/testutils.go index aaba3023..5f8682aa 100644 --- a/util/testutil/testutils.go +++ b/util/testutil/testutils.go @@ -58,6 +58,8 @@ func GetFileTimestamp(t *testing.T, path string) int64 { var TestCid = cid.NewCidV1(cid.Raw, util.Hash([]byte("test"))) +const TestWalletActorID = "f0808055" + const TestWalletAddr = "f1fib3pv7jua2ockdugtz7viz3cyy6lkhh7rfx3sa" const TestPrivateKeyHex = "7b2254797065223a22736563703235366b31222c22507269766174654b6579223a226b35507976337148327349586343595a58594f5775453149326e32554539436861556b6c4e36695a5763453d227d"