diff --git a/cmd/service/api.go b/cmd/service/api.go index 66ad3df..03d183e 100644 --- a/cmd/service/api.go +++ b/cmd/service/api.go @@ -50,6 +50,7 @@ func initApiServer(custodialContainer *custodial.Custodial) *echo.Echo { apiRoute.POST("/account/create", api.HandleAccountCreate(custodialContainer)) apiRoute.GET("/account/status/:address", api.HandleNetworkAccountStatus(custodialContainer)) apiRoute.POST("/sign/transfer", api.HandleSignTransfer(custodialContainer)) + apiRoute.POST("/sign/transferAuth", api.HandleSignTranserAuthorization(custodialContainer)) apiRoute.GET("/track/:trackingId", api.HandleTrackTx(custodialContainer)) return server diff --git a/cmd/service/main.go b/cmd/service/main.go index 4d66cf3..d365beb 100644 --- a/cmd/service/main.go +++ b/cmd/service/main.go @@ -58,6 +58,7 @@ func main() { natsConn, jsCtx := initJetStream() custodial, err := custodial.NewCustodial(custodial.Opts{ + ApprovalTimeout: ko.MustDuration("system.approve_timeout"), CeloProvider: celoProvider, LockProvider: lockProvider, Logg: lo, diff --git a/cmd/service/tasker.go b/cmd/service/tasker.go index e2b536d..b3fd640 100644 --- a/cmd/service/tasker.go +++ b/cmd/service/tasker.go @@ -37,6 +37,7 @@ func initTasker(custodialContainer *custodial.Custodial, redisPool *redis.RedisP taskerServer.RegisterHandlers(tasker.AccountRegisterTask, task.AccountRegisterOnChainProcessor(custodialContainer)) taskerServer.RegisterHandlers(tasker.AccountRefillGasTask, task.AccountRefillGasProcessor(custodialContainer)) taskerServer.RegisterHandlers(tasker.SignTransferTask, task.SignTransfer(custodialContainer)) + taskerServer.RegisterHandlers(tasker.SignTransferTaskAuth, task.SignTransferAuthorizationProcessor(custodialContainer)) taskerServer.RegisterHandlers(tasker.DispatchTxTask, task.DispatchTx(custodialContainer)) return taskerServer diff --git a/config.toml b/config.toml index c57d4f2..b44a717 100644 --- a/config.toml +++ b/config.toml @@ -13,6 +13,7 @@ registry_address = "" [system] private_key = "" public_key = "" +approve_timeout = "30m" [postgres] dsn = "" diff --git a/docs/docs.go b/docs/docs.go index c2a4b69..7a73f65 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -158,6 +158,66 @@ const docTemplate = `{ } } }, + "/sign/transferAuth": { + "post": { + "description": "Sign and dispatch a transfer authorization (approve) request.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "network" + ], + "summary": "Sign and dispatch a transfer authorization (approve) request.", + "parameters": [ + { + "description": "Sign Transfer Authorization (approve) Request", + "name": "signTransferAuthorzationRequest", + "in": "body", + "required": true, + "schema": { + "type": "object", + "properties": { + "amount": { + "type": "integer" + }, + "authorizedAddress": { + "type": "string" + }, + "authorizer": { + "type": "string" + }, + "voucherAddress": { + "type": "string" + } + } + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/api.OkResp" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.ErrResp" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.ErrResp" + } + } + } + } + }, "/track/{trackingId}": { "get": { "description": "Track an OTX (Origin transaction) status.", @@ -243,6 +303,8 @@ var SwaggerInfo = &swag.Spec{ Description: "Interact with CIC Custodial API", InfoInstanceName: "swagger", SwaggerTemplate: docTemplate, + LeftDelim: "{{", + RightDelim: "}}", } func init() { diff --git a/docs/swagger.json b/docs/swagger.json index 03bcfcf..6d179fb 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -150,6 +150,66 @@ } } }, + "/sign/transferAuth": { + "post": { + "description": "Sign and dispatch a transfer authorization (approve) request.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "network" + ], + "summary": "Sign and dispatch a transfer authorization (approve) request.", + "parameters": [ + { + "description": "Sign Transfer Authorization (approve) Request", + "name": "signTransferAuthorzationRequest", + "in": "body", + "required": true, + "schema": { + "type": "object", + "properties": { + "amount": { + "type": "integer" + }, + "authorizedAddress": { + "type": "string" + }, + "authorizer": { + "type": "string" + }, + "voucherAddress": { + "type": "string" + } + } + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/api.OkResp" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.ErrResp" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.ErrResp" + } + } + } + } + }, "/track/{trackingId}": { "get": { "description": "Track an OTX (Origin transaction) status.", diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 56f98f6..22adf23 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -117,6 +117,45 @@ paths: summary: Sign and dispatch transfer request. tags: - network + /sign/transferAuth: + post: + consumes: + - application/json + description: Sign and dispatch a transfer authorization (approve) request. + parameters: + - description: Sign Transfer Authorization (approve) Request + in: body + name: signTransferAuthorzationRequest + required: true + schema: + properties: + amount: + type: integer + authorizedAddress: + type: string + authorizer: + type: string + voucherAddress: + type: string + type: object + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/api.OkResp' + "400": + description: Bad Request + schema: + $ref: '#/definitions/api.ErrResp' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.ErrResp' + summary: Sign and dispatch a transfer authorization (approve) request. + tags: + - network /track/{trackingId}: get: consumes: diff --git a/go.mod b/go.mod index 4997ba3..c6201a0 100644 --- a/go.mod +++ b/go.mod @@ -20,7 +20,6 @@ require ( github.com/knadh/koanf/providers/file v0.1.0 github.com/knadh/koanf/v2 v2.0.1 github.com/labstack/echo/v4 v4.10.2 - github.com/labstack/gommon v0.4.0 github.com/nats-io/nats.go v1.25.0 github.com/redis/go-redis/v9 v9.0.4 github.com/swaggo/echo-swagger v1.4.0 @@ -77,6 +76,7 @@ require ( github.com/jackpal/go-nat-pmp v1.0.2 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/knadh/koanf/maps v0.1.1 // indirect + github.com/labstack/gommon v0.4.0 // indirect github.com/leodido/go-urn v1.2.3 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-colorable v0.1.13 // indirect diff --git a/go.sum b/go.sum index 4b6a51e..f619860 100644 --- a/go.sum +++ b/go.sum @@ -275,10 +275,6 @@ github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/ad github.com/graph-gophers/graphql-go v1.3.0/go.mod h1:9CQHMSxwO4MprSdzoIEobiHpoLtHm77vfxsvsIN5Vuc= github.com/grassrootseconomics/asynq v0.25.0 h1:2zSz5YwNLu/oCTm/xfNixn86i9aw4zth9Dl0dc2kFEs= github.com/grassrootseconomics/asynq v0.25.0/go.mod h1:pe2XOdK1eIbTgTmRFHIYl75lvVuTPJxZq2T9Ocz/+2s= -github.com/grassrootseconomics/celoutils v1.2.1 h1:ndM4h7Df0d57m2kdRXRStrnunqOL61wQ51rnOanX1KI= -github.com/grassrootseconomics/celoutils v1.2.1/go.mod h1:Uo5YRy6AGLAHDZj9jaOI+AWoQ1H3L0v79728pPMkm9Q= -github.com/grassrootseconomics/celoutils v1.3.0 h1:0NTdYh0jboGlVnfML7fbgWLjrC0jA+F1J/Ze01IkNlY= -github.com/grassrootseconomics/celoutils v1.3.0/go.mod h1:Uo5YRy6AGLAHDZj9jaOI+AWoQ1H3L0v79728pPMkm9Q= github.com/grassrootseconomics/celoutils v1.4.0 h1:AJNKiOpfnQqZ3kRxeUlhWH/zlDDjhtbs/OzAMb5zU4A= github.com/grassrootseconomics/celoutils v1.4.0/go.mod h1:Uo5YRy6AGLAHDZj9jaOI+AWoQ1H3L0v79728pPMkm9Q= github.com/grassrootseconomics/w3-celo-patch v0.2.0 h1:YqibbPzX0tQKmxU1nUGzThPKk/fiYeYZY6Aif3eyu8U= diff --git a/internal/api/sign.go b/internal/api/sign_transfer.go similarity index 97% rename from internal/api/sign.go rename to internal/api/sign_transfer.go index 7cea418..1a37ed8 100644 --- a/internal/api/sign.go +++ b/internal/api/sign_transfer.go @@ -31,7 +31,7 @@ func HandleSignTransfer(cu *custodial.Custodial) func(echo.Context) error { From string `json:"from" validate:"required,eth_addr_checksum"` To string `json:"to" validate:"required,eth_addr_checksum"` VoucherAddress string `json:"voucherAddress" validate:"required,eth_addr_checksum"` - Amount uint64 `json:"amount" validate:"required"` + Amount uint64 `json:"amount" validate:"gt=0"` } ) diff --git a/internal/api/sign_transfer_auth.go b/internal/api/sign_transfer_auth.go new file mode 100644 index 0000000..410f166 --- /dev/null +++ b/internal/api/sign_transfer_auth.go @@ -0,0 +1,107 @@ +package api + +import ( + "encoding/json" + "net/http" + + "github.com/google/uuid" + "github.com/grassrootseconomics/cic-custodial/internal/custodial" + "github.com/grassrootseconomics/cic-custodial/internal/tasker" + "github.com/grassrootseconomics/cic-custodial/internal/tasker/task" + "github.com/labstack/echo/v4" +) + +// Max 10k vouchers per approval session +const approvalSafetyLimit = 10000 * 1000000 + +// HandleSignTransferAuthorization godoc +// +// @Summary Sign and dispatch a transfer authorization (approve) request. +// @Description Sign and dispatch a transfer authorization (approve) request. +// @Tags network +// @Accept json +// @Produce json +// @Param signTransferAuthorzationRequest body object{amount=uint64,authorizer=string,authorizedAddress=string,voucherAddress=string} true "Sign Transfer Authorization (approve) Request" +// @Success 200 {object} OkResp +// @Failure 400 {object} ErrResp +// @Failure 500 {object} ErrResp +// @Router /sign/transferAuth [post] +func HandleSignTranserAuthorization(cu *custodial.Custodial) func(echo.Context) error { + return func(c echo.Context) error { + var ( + req struct { + Amount uint64 `json:"amount" validate:"gte=0"` + Authorizer string `json:"authorizer" validate:"required,eth_addr_checksum"` + AuthorizedAddress string `json:"authorizedAddress" validate:"required,eth_addr_checksum"` + VoucherAddress string `json:"voucherAddress" validate:"required,eth_addr_checksum"` + } + ) + + if err := c.Bind(&req); err != nil { + return NewBadRequestError(ErrInvalidJSON) + } + + if err := c.Validate(req); err != nil { + return err + } + + accountActive, gasLock, err := cu.Store.GetAccountStatus(c.Request().Context(), req.Authorizer) + if err != nil { + return err + } + + if req.Amount > approvalSafetyLimit { + return c.JSON(http.StatusForbidden, ErrResp{ + Ok: false, + Message: "Approval amount per session exceeds 10k.", + }) + } + + if !accountActive { + return c.JSON(http.StatusForbidden, ErrResp{ + Ok: false, + Message: "Account pending activation. Try again later.", + }) + } + + if gasLock { + return c.JSON(http.StatusForbidden, ErrResp{ + Ok: false, + Message: "Gas lock. Gas balance unavailable. Try again later.", + }) + } + + trackingId := uuid.NewString() + + taskPayload, err := json.Marshal(task.TransferAuthPayload{ + TrackingId: trackingId, + Amount: req.Amount, + Authorizer: req.Authorizer, + AuthorizedAddress: req.AuthorizedAddress, + VoucherAddress: req.VoucherAddress, + }) + if err != nil { + return err + } + + _, err = cu.TaskerClient.CreateTask( + c.Request().Context(), + tasker.SignTransferTaskAuth, + tasker.DefaultPriority, + &tasker.Task{ + Id: trackingId, + Payload: taskPayload, + }, + ) + if err != nil { + return err + } + + return c.JSON(http.StatusOK, OkResp{ + Ok: true, + Result: H{ + "trackingId": trackingId, + }, + }) + } +} diff --git a/internal/custodial/abis.go b/internal/custodial/abis.go index c20658b..3b90d5d 100644 --- a/internal/custodial/abis.go +++ b/internal/custodial/abis.go @@ -3,6 +3,7 @@ package custodial import "github.com/grassrootseconomics/w3-celo-patch" const ( + Approve = "approve" Check = "check" GiveTo = "giveTo" MintTo = "mintTo" @@ -16,12 +17,12 @@ const ( // Any relevant function signature that will be used by the custodial system can be defined here. func initAbis() map[string]*w3.Func { return map[string]*w3.Func{ - Check: w3.MustNewFunc("check(address)", "bool"), - GiveTo: w3.MustNewFunc("giveTo(address)", "uint256"), - MintTo: w3.MustNewFunc("mintTo(address, uint256)", "bool"), - NextTime: w3.MustNewFunc("nextTime(address)", "uint256"), - Register: w3.MustNewFunc("register(address)", ""), - Transfer: w3.MustNewFunc("transfer(address,uint256)", "bool"), - TransferFrom: w3.MustNewFunc("transferFrom(address, address, uint256)", "bool"), + Approve: w3.MustNewFunc("approve(address, uint256)", "bool"), + Check: w3.MustNewFunc("check(address)", "bool"), + GiveTo: w3.MustNewFunc("giveTo(address)", "uint256"), + MintTo: w3.MustNewFunc("mintTo(address, uint256)", "bool"), + NextTime: w3.MustNewFunc("nextTime(address)", "uint256"), + Register: w3.MustNewFunc("register(address)", ""), + Transfer: w3.MustNewFunc("transfer(address,uint256)", "bool"), } } diff --git a/internal/custodial/custodial.go b/internal/custodial/custodial.go index 887d9f8..3ba6e00 100644 --- a/internal/custodial/custodial.go +++ b/internal/custodial/custodial.go @@ -3,6 +3,7 @@ package custodial import ( "context" "crypto/ecdsa" + "time" "github.com/bsm/redislock" "github.com/celo-org/celo-blockchain/common" @@ -19,6 +20,7 @@ import ( type ( Opts struct { + ApprovalTimeout time.Duration CeloProvider *celoutils.Provider LockProvider *redislock.Client Logg logf.Logger @@ -32,6 +34,7 @@ type ( } Custodial struct { + ApprovalTimeout time.Duration Abis map[string]*w3.Func CeloProvider *celoutils.Provider LockProvider *redislock.Client @@ -69,6 +72,7 @@ func NewCustodial(o Opts) (*Custodial, error) { } return &Custodial{ + ApprovalTimeout: o.ApprovalTimeout, Abis: initAbis(), CeloProvider: o.CeloProvider, LockProvider: o.LockProvider, diff --git a/internal/tasker/task/account_refill_gas.go b/internal/tasker/task/account_refill_gas.go index 1f4cff6..3e69005 100644 --- a/internal/tasker/task/account_refill_gas.go +++ b/internal/tasker/task/account_refill_gas.go @@ -17,10 +17,6 @@ import ( "github.com/hibiken/asynq" ) -const ( - gasGiveToLimit = 250000 -) - func AccountRefillGasProcessor(cu *custodial.Custodial) func(context.Context, *asynq.Task) error { return func(ctx context.Context, t *asynq.Task) error { var ( diff --git a/internal/tasker/task/sign_transfer.go b/internal/tasker/task/sign_transfer.go index 33deaac..4b0823c 100644 --- a/internal/tasker/task/sign_transfer.go +++ b/internal/tasker/task/sign_transfer.go @@ -60,13 +60,16 @@ func SignTransfer(cu *custodial.Custodial) func(context.Context, *asynq.Task) er } defer func() { if err != nil { - if nErr := cu.Noncestore.Return(ctx, cu.SystemPublicKey); nErr != nil { + if nErr := cu.Noncestore.Return(ctx, payload.From); nErr != nil { err = nErr } } }() - input, err := cu.Abis[custodial.Transfer].EncodeArgs(celoutils.HexToAddress(payload.To), new(big.Int).SetUint64(payload.Amount)) + input, err := cu.Abis[custodial.Transfer].EncodeArgs( + celoutils.HexToAddress(payload.To), + new(big.Int).SetUint64(payload.Amount), + ) if err != nil { return err } diff --git a/internal/tasker/task/sign_transfer_auth.go b/internal/tasker/task/sign_transfer_auth.go new file mode 100644 index 0000000..9387f59 --- /dev/null +++ b/internal/tasker/task/sign_transfer_auth.go @@ -0,0 +1,196 @@ +package task + +import ( + "context" + "encoding/json" + "math/big" + + "github.com/bsm/redislock" + "github.com/celo-org/celo-blockchain/common/hexutil" + "github.com/grassrootseconomics/celoutils" + "github.com/grassrootseconomics/cic-custodial/internal/custodial" + "github.com/grassrootseconomics/cic-custodial/internal/store" + "github.com/grassrootseconomics/cic-custodial/internal/tasker" + "github.com/grassrootseconomics/cic-custodial/pkg/enum" + "github.com/grassrootseconomics/w3-celo-patch/module/eth" + "github.com/hibiken/asynq" +) + +type TransferAuthPayload struct { + Amount uint64 `json:"amount"` + Authorizer string `json:"authorizer"` + AuthorizedAddress string `json:"authorizedAddress"` + TrackingId string `json:"trackingId"` + VoucherAddress string `json:"voucherAddress"` +} + +func SignTransferAuthorizationProcessor(cu *custodial.Custodial) func(context.Context, *asynq.Task) error { + return func(ctx context.Context, t *asynq.Task) error { + var ( + err error + networkBalance big.Int + payload TransferAuthPayload + ) + + if err := json.Unmarshal(t.Payload(), &payload); err != nil { + return err + } + + lock, err := cu.LockProvider.Obtain( + ctx, + lockPrefix+payload.Authorizer, + lockTimeout, + &redislock.Options{ + RetryStrategy: lockRetry(), + }, + ) + if err != nil { + return err + } + defer lock.Release(ctx) + + key, err := cu.Store.LoadPrivateKey(ctx, payload.Authorizer) + if err != nil { + return err + } + + nonce, err := cu.Noncestore.Acquire(ctx, payload.Authorizer) + if err != nil { + return err + } + defer func() { + if err != nil { + if nErr := cu.Noncestore.Return(ctx, payload.Authorizer); nErr != nil { + err = nErr + } + } + }() + + input, err := cu.Abis[custodial.Approve].EncodeArgs( + celoutils.HexToAddress(payload.AuthorizedAddress), + new(big.Int).SetUint64(payload.Amount), + ) + if err != nil { + return err + } + + builtTx, err := cu.CeloProvider.SignContractExecutionTx( + key, + celoutils.ContractExecutionTxOpts{ + ContractAddress: celoutils.HexToAddress(payload.VoucherAddress), + InputData: input, + GasFeeCap: celoutils.SafeGasFeeCap, + GasTipCap: celoutils.SafeGasTipCap, + GasLimit: uint64(celoutils.SafeGasLimit), + Nonce: nonce, + }, + ) + if err != nil { + return err + } + + rawTx, err := builtTx.MarshalBinary() + if err != nil { + return err + } + + id, err := cu.Store.CreateOtx(ctx, store.Otx{ + TrackingId: payload.TrackingId, + Type: enum.TRANSFER_AUTH, + RawTx: hexutil.Encode(rawTx), + TxHash: builtTx.Hash().Hex(), + From: cu.SystemPublicKey, + Data: hexutil.Encode(builtTx.Data()), + GasPrice: builtTx.GasPrice(), + GasLimit: builtTx.Gas(), + TransferValue: 0, + Nonce: builtTx.Nonce(), + }) + if err != nil { + return err + } + + if err := cu.CeloProvider.Client.CallCtx( + ctx, + eth.Balance(celoutils.HexToAddress(payload.Authorizer), nil).Returns(&networkBalance), + ); err != nil { + return err + } + + disptachJobPayload, err := json.Marshal(TxPayload{ + OtxId: id, + Tx: builtTx, + }) + if err != nil { + return err + } + + _, err = cu.TaskerClient.CreateTask( + ctx, + tasker.DispatchTxTask, + tasker.HighPriority, + &tasker.Task{ + Payload: disptachJobPayload, + }, + ) + if err != nil { + return err + } + + // Auto-revoke every session (15 min) + // Check if already a revoke request + if payload.Amount > 0 { + taskPayload, err := json.Marshal(TransferAuthPayload{ + TrackingId: payload.TrackingId, + Amount: 0, + Authorizer: payload.Authorizer, + AuthorizedAddress: payload.AuthorizedAddress, + VoucherAddress: payload.VoucherAddress, + }) + if err != nil { + return err + } + + _, err = cu.TaskerClient.CreateTask( + ctx, + tasker.SignTransferTaskAuth, + tasker.DefaultPriority, + &tasker.Task{ + Payload: taskPayload, + }, + asynq.ProcessIn(cu.ApprovalTimeout), + ) + if err != nil { + return err + } + } + + gasRefillPayload, err := json.Marshal(AccountPayload{ + PublicKey: payload.Authorizer, + TrackingId: payload.TrackingId, + }) + if err != nil { + return err + } + + if !balanceCheck(networkBalance) { + if err := cu.Store.GasLock(ctx, payload.Authorizer); err != nil { + return err + } + + _, err = cu.TaskerClient.CreateTask( + ctx, + tasker.AccountRefillGasTask, + tasker.DefaultPriority, + &tasker.Task{ + Payload: gasRefillPayload, + }, + ) + if err != nil { + return err + } + } + + return nil + } +} diff --git a/internal/tasker/types.go b/internal/tasker/types.go index a321ad8..bc8aacd 100644 --- a/internal/tasker/types.go +++ b/internal/tasker/types.go @@ -18,6 +18,7 @@ const ( AccountRegisterTask TaskName = "sys:register_account" AccountRefillGasTask TaskName = "sys:refill_gas" SignTransferTask TaskName = "usr:sign_transfer" + SignTransferTaskAuth TaskName = "usr:sign_transfer_auth" DispatchTxTask TaskName = "rpc:dispatch" ) diff --git a/migrations/006_transfer_auth.sql b/migrations/006_transfer_auth.sql new file mode 100644 index 0000000..265ba0f --- /dev/null +++ b/migrations/006_transfer_auth.sql @@ -0,0 +1 @@ +INSERT INTO otx_tx_type (value) VALUES ('TRANSFER_AUTHORIZATION'); diff --git a/pkg/enum/enum.go b/pkg/enum/enum.go index 5671e95..841d687 100644 --- a/pkg/enum/enum.go +++ b/pkg/enum/enum.go @@ -21,5 +21,6 @@ const ( ACCOUNT_REGISTER OtxType = "ACCOUNT_REGISTER" REFILL_GAS OtxType = "REFILL_GAS" + TRANSFER_AUTH OtxType = "TRANSFER_AUTHORIZATION" TRANSFER_VOUCHER OtxType = "TRANSFER_VOUCHER" )