From e9158f4c17f04364468081807f37010d62b82ff9 Mon Sep 17 00:00:00 2001 From: Maksym Hrynenko Date: Fri, 31 May 2024 20:01:47 +0300 Subject: [PATCH] add: requests to estimate and transfer ERC20; update: configs with price api and contracts; --- config.yaml | 10 +- internal/cli/main.go | 2 +- internal/config/main.go | 2 + internal/config/price.go | 129 +++++++++++++++ internal/service/api/handlers/get_airdrop.go | 43 +++++ .../api/handlers/get_transfer_params.go | 150 +++++++++++++++++ .../service/api/handlers/send_transfer.go | 52 ++++++ .../service/api/requests/transfer_token.go | 154 ++++++++++++++++++ internal/service/router.go | 54 +++--- resources/model_resource_type.go | 1 + 10 files changed, 576 insertions(+), 21 deletions(-) create mode 100644 internal/config/price.go create mode 100644 internal/service/api/handlers/get_airdrop.go create mode 100644 internal/service/api/handlers/get_transfer_params.go create mode 100644 internal/service/api/handlers/send_transfer.go create mode 100644 internal/service/api/requests/transfer_token.go diff --git a/config.yaml b/config.yaml index bf92fb5..8d6cccc 100644 --- a/config.yaml +++ b/config.yaml @@ -17,11 +17,13 @@ broadcaster: chain_id: chain_id sender_private_key: priv_key query_limit: 10 + erc20_permit_transfer: contract_address + gas_multiplier: 1.1 verifier: verification_key_path: "./verification_key.json" allowed_age: 18 - allowed_citizenships: ["UKR"] + allowed_citizenships: [ "UKR" ] allowed_event_id: "event_id" allowed_query_selector: "query_selector" # at least one of these should be correct to pass: @@ -32,3 +34,9 @@ root_verifier: rpc: evm_rpc_url contract: registration_contract_address request_timeout: 10s + +price_api: + url: api_url #coinmarketcap + key: api_key + currency_id: 23888 + quote_tag: "ETH" \ No newline at end of file diff --git a/internal/cli/main.go b/internal/cli/main.go index 2791721..d16a46b 100644 --- a/internal/cli/main.go +++ b/internal/cli/main.go @@ -8,9 +8,9 @@ import ( "syscall" "github.com/alecthomas/kingpin" - "github.com/rarimo/evm-airdrop-svc/internal/broadcaster" "github.com/rarimo/evm-airdrop-svc/internal/config" "github.com/rarimo/evm-airdrop-svc/internal/service" + "github.com/rarimo/evm-airdrop-svc/internal/service/broadcaster" "gitlab.com/distributed_lab/kit/kv" "gitlab.com/distributed_lab/logan/v3" ) diff --git a/internal/config/main.go b/internal/config/main.go index 27a65f9..1115d3f 100644 --- a/internal/config/main.go +++ b/internal/config/main.go @@ -14,6 +14,7 @@ type Config struct { identity.VerifierProvider Broadcasterer AirdropConfiger + PriceApiConfiger airdrop comfig.Once verifier comfig.Once @@ -29,5 +30,6 @@ func New(getter kv.Getter) *Config { VerifierProvider: identity.NewVerifierProvider(getter), Broadcasterer: NewBroadcaster(getter), AirdropConfiger: NewAirdropConfiger(getter), + PriceApiConfiger: NewPriceApiConfiger(getter), } } diff --git a/internal/config/price.go b/internal/config/price.go new file mode 100644 index 0000000..a060349 --- /dev/null +++ b/internal/config/price.go @@ -0,0 +1,129 @@ +package config + +import ( + "encoding/json" + "io" + "math/big" + "net/http" + "net/url" + "time" + + "gitlab.com/distributed_lab/figure/v3" + "gitlab.com/distributed_lab/kit/comfig" + "gitlab.com/distributed_lab/kit/kv" + "gitlab.com/distributed_lab/logan/v3" + "gitlab.com/distributed_lab/logan/v3/errors" +) + +const priceApiYamlKey = "price_api" + +var ( + ErrPriceApiRequestFailed = errors.New("failed to fetch price api") + ErrEmptyPrice = errors.New("dollar price in ETH is empty") +) + +type PriceApiConfiger interface { + PriceApiConfig() PriceApiConfig +} + +type PriceApiConfig struct { + URL *url.URL `fig:"url,required"` + Key string `fig:"key,required"` + CurrencyId string `fig:"currency_id,required"` + QuoteTag string `fig:"quote_tag,required"` +} + +type priceApi struct { + once comfig.Once + getter kv.Getter +} + +func NewPriceApiConfiger(getter kv.Getter) PriceApiConfiger { + return &priceApi{ + getter: getter, + } +} + +func (v *priceApi) PriceApiConfig() PriceApiConfig { + return v.once.Do(func() interface{} { + var result PriceApiConfig + + err := figure. + Out(&result). + With(figure.BaseHooks). + From(kv.MustGetStringMap(v.getter, priceApiYamlKey)). + Please() + if err != nil { + panic(errors.Wrap(err, "failed to figure out config", logan.F{ + "yaml_key": priceApiYamlKey, + })) + } + + return result + }).(PriceApiConfig) +} + +type QuoteResponse struct { + Data map[string]Currency `json:"data"` +} + +type Currency struct { + Id int `json:"id"` + Name string `json:"name"` + Symbol string `json:"symbol"` + Quote map[string]Quote `json:"quote"` +} + +type Quote struct { + Price float64 `json:"price"` + LastUpdated time.Time `json:"last_updated"` +} + +// ConvertPrice converts tokens price +func (cfg PriceApiConfig) ConvertPrice() (*big.Float, error) { + URL := cfg.URL.JoinPath("/v2/cryptocurrency/quotes/latest") + + query := URL.Query() + query.Set("id", cfg.CurrencyId) + query.Set("convert", cfg.QuoteTag) + + URL.RawQuery = query.Encode() + + request, err := http.NewRequest(http.MethodGet, URL.String(), nil) + if err != nil { + return nil, errors.Wrap(err, "failed to create request", logan.F{ + "url": URL, + }) + } + + request.Header.Set("X-CMC_PRO_API_KEY", cfg.Key) + + response, err := http.DefaultClient.Do(request) + if err != nil { + return nil, errors.Wrap(err, "failed to do request") + } + + if response.StatusCode != http.StatusOK { + body, err := io.ReadAll(response.Body) + if err != nil { + return nil, errors.Wrap(err, "failed to read response body") + } + + return nil, errors.From(ErrPriceApiRequestFailed, logan.F{ + "status": response.StatusCode, + "body": string(body), + }) + } + + var body QuoteResponse + if err = json.NewDecoder(response.Body).Decode(&body); err != nil { + return nil, errors.Wrap(err, "failed to decode response body") + } + + dollarInEth := big.NewFloat(body.Data[cfg.CurrencyId].Quote[cfg.QuoteTag].Price) + if dollarInEth.Cmp(big.NewFloat(0)) == 0 { + return nil, ErrEmptyPrice + } + + return dollarInEth, nil +} diff --git a/internal/service/api/handlers/get_airdrop.go b/internal/service/api/handlers/get_airdrop.go new file mode 100644 index 0000000..f3279fc --- /dev/null +++ b/internal/service/api/handlers/get_airdrop.go @@ -0,0 +1,43 @@ +package handlers + +import ( + "net/http" + + "github.com/rarimo/evm-airdrop-svc/internal/data" + "github.com/rarimo/evm-airdrop-svc/internal/service/api" + "github.com/rarimo/evm-airdrop-svc/internal/service/api/models" + "github.com/rarimo/evm-airdrop-svc/internal/service/api/requests" + "gitlab.com/distributed_lab/ape" + "gitlab.com/distributed_lab/ape/problems" +) + +func GetAirdrop(w http.ResponseWriter, r *http.Request) { + nullifier, err := requests.NewGetAirdrop(r) + if err != nil { + ape.RenderErr(w, problems.BadRequest(err)...) + return + } + + airdrops, err := api.AirdropsQ(r). + FilterByNullifier(nullifier). + Select() + if err != nil { + api.Log(r).WithError(err).Error("Failed to select airdrops by nullifier") + ape.RenderErr(w, problems.InternalError()) + return + } + if len(airdrops) == 0 { + ape.RenderErr(w, problems.NotFound()) + return + } + + airdrop := airdrops[0] + for _, a := range airdrops[1:] { + if a.Status == data.TxStatusCompleted { + airdrop = a + break + } + } + + ape.Render(w, models.NewAirdropResponse(airdrop)) +} diff --git a/internal/service/api/handlers/get_transfer_params.go b/internal/service/api/handlers/get_transfer_params.go new file mode 100644 index 0000000..6ce0393 --- /dev/null +++ b/internal/service/api/handlers/get_transfer_params.go @@ -0,0 +1,150 @@ +package handlers + +import ( + "math/big" + "net/http" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core/types" + validation "github.com/go-ozzo/ozzo-validation/v4" + pkgErrors "github.com/pkg/errors" + "github.com/rarimo/evm-airdrop-svc/internal/service/api" + "github.com/rarimo/evm-airdrop-svc/internal/service/api/models" + "github.com/rarimo/evm-airdrop-svc/internal/service/api/requests" + "github.com/rarimo/evm-airdrop-svc/resources" + "gitlab.com/distributed_lab/ape" + "gitlab.com/distributed_lab/ape/problems" + "gitlab.com/distributed_lab/logan/v3" + "gitlab.com/distributed_lab/logan/v3/errors" +) + +var ErrInsufficienciesAmount = errors.New("amount is insufficient to pay tx fee") + +type TransferTxParams struct { + amount *big.Int + fee *big.Int + gasPrice *big.Int + gasLimit uint64 + noSend bool +} + +func GetTransferParams(w http.ResponseWriter, r *http.Request) { + req, err := requests.NewTransferERC20Token(r) + if err != nil { + api.Log(r).WithError(err).Error("failed to parse request") + ape.RenderErr(w, problems.BadRequest(err)...) + return + } + + txParams, err := EstimateTransfer(r, req.Data.Attributes) + if err != nil { + api.Log(r).WithError(err).Error("failed to estimate transfer transaction") + if pkgErrors.Is(err, ErrInsufficienciesAmount) { + ape.RenderErr(w, problems.BadRequest(validation.Errors{ + "data/attributes/amount": errors.From(err, logan.F{ + "amount": txParams.amount, + "fee": txParams.fee, + }), + })...) + return + } + ape.RenderErr(w, problems.InternalError()) + return + } + + ape.Render(w, models.NewEstimateResponse(txParams.amount, txParams.fee)) +} + +func EstimateTransfer(r *http.Request, attr resources.TransferErc20TokenAttributes) (*TransferTxParams, error) { + halfAmount := new(big.Int).Div(attr.Amount, big.NewInt(2)) + + tx, err := MakeTransferWithPermitTx(r, attr, TransferTxParams{ + noSend: true, + fee: halfAmount, + amount: halfAmount, + }) + if err != nil { + return nil, errors.Wrap(err, "failed to build transfer tx") + } + + broadcaster := api.Broadcaster(r) + gasPrice := broadcaster.MultiplyGasPrice(tx.GasPrice()) + feeAmount, err := buildFeeTransferAmount(r, gasPrice, tx.Gas()) + if err != nil { + return nil, errors.Wrap(err, "failed to build fee transfer amount") + } + + amount := new(big.Int).Sub(attr.Amount, feeAmount) + + params := TransferTxParams{ + amount: amount, + fee: feeAmount, + gasPrice: gasPrice, + gasLimit: tx.Gas(), + } + + if amount.Cmp(new(big.Int)) != 1 { + return ¶ms, ErrInsufficienciesAmount + } + + return ¶ms, nil +} + +func MakeTransferWithPermitTx( + r *http.Request, + attr resources.TransferErc20TokenAttributes, + params TransferTxParams, +) (*types.Transaction, error) { + var ( + R [32]byte + S [32]byte + ) + + txOptions, err := bind.NewKeyedTransactorWithChainID(api.Broadcaster(r).PrivateKey, api.Broadcaster(r).ChainID) + if err != nil { + return nil, errors.Wrap(err, "failed to get tx options") + } + txOptions.NoSend = params.noSend + txOptions.GasPrice = params.gasPrice + txOptions.GasLimit = params.gasLimit + + copy(R[:], hexutil.MustDecode(attr.R)) + copy(S[:], hexutil.MustDecode(attr.S)) + + tx, err := api.ERC20PermitTransfer(r).TransferWithPermit( + txOptions, + api.AirdropConfig(r).TokenAddress, + attr.Sender, + attr.Receiver, + params.amount, + params.fee, + attr.Deadline, + attr.V, + R, + S, + ) + if err != nil { + return nil, errors.Wrap(err, "failed to build transfer with permit transaction") + } + + return tx, nil +} + +func buildFeeTransferAmount(r *http.Request, gweiGasPrice *big.Int, gasLimit uint64) (*big.Int, error) { + dollarInEth, err := api.PriceApiConfig(r).ConvertPrice() + if err != nil { + return nil, errors.Wrap(err, "failed to convert dollar price in eth") + } + + // Convert GWEI gas price to ETH gas price + ethGasPrice := new(big.Float).Quo(new(big.Float).SetInt(gweiGasPrice), big.NewFloat(1e18)) + // Convert ETH gas price to dollar correspondence + gasPriceInGlo := new(big.Float).Quo(ethGasPrice, dollarInEth) + + feeAmount := new(big.Float).Mul(new(big.Float).SetUint64(gasLimit), gasPriceInGlo) + + amount, _ := feeAmount.Int(nil) + + return amount, nil +} diff --git a/internal/service/api/handlers/send_transfer.go b/internal/service/api/handlers/send_transfer.go new file mode 100644 index 0000000..8826645 --- /dev/null +++ b/internal/service/api/handlers/send_transfer.go @@ -0,0 +1,52 @@ +package handlers + +import ( + "net/http" + + validation "github.com/go-ozzo/ozzo-validation/v4" + pkgErrors "github.com/pkg/errors" + "github.com/rarimo/evm-airdrop-svc/internal/service/api" + "github.com/rarimo/evm-airdrop-svc/internal/service/api/models" + "github.com/rarimo/evm-airdrop-svc/internal/service/api/requests" + "gitlab.com/distributed_lab/ape" + "gitlab.com/distributed_lab/ape/problems" + "gitlab.com/distributed_lab/logan/v3" + "gitlab.com/distributed_lab/logan/v3/errors" +) + +func SendTransfer(w http.ResponseWriter, r *http.Request) { + req, err := requests.NewTransferERC20Token(r) + if err != nil { + api.Log(r).WithError(err).Error("failed to parse request") + ape.RenderErr(w, problems.BadRequest(err)...) + return + } + + txParams, err := EstimateTransfer(r, req.Data.Attributes) + if err != nil { + api.Log(r).WithError(err).Error("failed to estimate transfer transaction") + if pkgErrors.Is(err, ErrInsufficienciesAmount) { + ape.RenderErr(w, problems.BadRequest(validation.Errors{ + "data/attributes/amount": errors.From(err, logan.F{ + "amount": txParams.amount, + "fee": txParams.fee, + }), + })...) + return + } + ape.RenderErr(w, problems.InternalError()) + return + } + + txParams.noSend = false + tx, err := MakeTransferWithPermitTx(r, req.Data.Attributes, *txParams) + if err != nil { + api.Log(r).WithError(err).Error("failed to build transfer transaction", logan.F{ + "params": txParams, + }) + ape.RenderErr(w, problems.InternalError()) + return + } + + ape.Render(w, models.NewTxResponse(txParams.amount, txParams.fee, tx.Hash().Hex())) +} diff --git a/internal/service/api/requests/transfer_token.go b/internal/service/api/requests/transfer_token.go new file mode 100644 index 0000000..a51e4fd --- /dev/null +++ b/internal/service/api/requests/transfer_token.go @@ -0,0 +1,154 @@ +package requests + +import ( + "bytes" + "encoding/json" + "fmt" + "math/big" + "net/http" + "regexp" + "time" + + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/common/math" + "github.com/ethereum/go-ethereum/crypto" + val "github.com/go-ozzo/ozzo-validation/v4" + "github.com/rarimo/evm-airdrop-svc/internal/service/api" + "github.com/rarimo/evm-airdrop-svc/resources" + "gitlab.com/distributed_lab/logan/v3" + "gitlab.com/distributed_lab/logan/v3/errors" +) + +var hexRegExp = regexp.MustCompile("0[xX][0-9a-fA-F]+") + +func NewTransferERC20Token(r *http.Request) (req resources.TransferErc20TokenRequest, err error) { + if err = json.NewDecoder(r.Body).Decode(&req); err != nil { + return req, newDecodeError("body", err) + } + + attr := req.Data.Attributes + + err = val.Errors{ + "data/type": val.Validate(req.Data.Type, val.Required, val.In(resources.TRANSFER_ERC20)), + "data/attributes/sender": val.Validate(attr.Sender.String(), val.Required, val.Match(ethAddrRegExp)), + "data/attributes/receiver": val.Validate(attr.Receiver.String(), val.Required, val.Match(ethAddrRegExp)), + "data/attributes/amount": val.Validate(attr.Amount.Int64(), val.Required, val.Min(0)), + "data/attributes/deadline": val.Validate(attr.Deadline.Int64(), val.Required, val.By(UnixTimestampRule)), + "data/attributes/R": val.Validate(attr.R, val.Required, val.Match(hexRegExp)), + "data/attributes/S": val.Validate(attr.S, val.Required, val.Match(hexRegExp)), + "data/attributes/V": val.Validate(attr.V, val.Required), + }.Filter() + if err != nil { + return req, err + } + + decimals := math.BigPow(1, 18) + attr.Amount = new(big.Int).Mul(req.Data.Attributes.Amount, decimals) + + if err = VerifyPermitSignature(r, attr); err != nil { + return req, val.Errors{ + "signature": errors.Wrap(err, "invalid permit signature"), + } + } + + req.Data.Attributes.Amount = new(big.Int).Mul(req.Data.Attributes.Amount, decimals) + + return req, nil +} + +func UnixTimestampRule(value interface{}) error { + parsedTimestamp, ok := value.(int64) + if !ok { + return errors.From(errors.New("must be a valid integer"), logan.F{ + "value": value, + }) + } + + timestamp := time.Unix(parsedTimestamp, 0) + if timestamp.IsZero() { + return errors.From(errors.New("timestamp is empty"), logan.F{ + "timestamp": timestamp, + }) + } + + return nil +} + +func VerifyPermitSignature(r *http.Request, attrs resources.TransferErc20TokenAttributes) error { + sigHash, err := buildMessage(r, attrs) + if err != nil { + return errors.Wrap(err, "failed to build hash message") + } + + rawSignature := make([]byte, 65) + copy(rawSignature[:32], hexutil.MustDecode(attrs.R)[:]) + copy(rawSignature[32:64], hexutil.MustDecode(attrs.S)[:]) + rawSignature[64] = attrs.V - 27 + + pubKey, err := crypto.SigToPub(sigHash, rawSignature) + if err != nil { + return errors.Wrap(err, "failed to recover public key from signature") + } + recoveredAddr := crypto.PubkeyToAddress(*pubKey) + + if bytes.Compare(recoveredAddr.Bytes(), attrs.Sender.Bytes()) != 0 { + fmt.Println(recoveredAddr.Hex()) + fmt.Println(attrs.Sender.Hex()) + return errors.New("recovered pubkey is invalid") + } + + return nil +} + +func buildMessage(r *http.Request, attrs resources.TransferErc20TokenAttributes) ([]byte, error) { + nonce, err := api.ERC20Permit(r).Nonces(&bind.CallOpts{}, attrs.Sender) + if err != nil { + return nil, errors.Wrap(err, "failed to get nonce", logan.F{"addr": attrs.Sender}) + } + + domainSeparator, err := api.ERC20Permit(r).DOMAINSEPARATOR(&bind.CallOpts{}) + if err != nil { + return nil, errors.Wrap(err, "failed to get domain separator") + } + + uint256Ty, _ := abi.NewType("uint256", "uint256", nil) + bytes32Ty, _ := abi.NewType("bytes32", "bytes32", nil) + addressTy, _ := abi.NewType("address", "address", nil) + + args := abi.Arguments{ + {Type: bytes32Ty}, + {Type: addressTy}, + {Type: addressTy}, + {Type: uint256Ty}, + {Type: uint256Ty}, + {Type: uint256Ty}, + } + + permitTypeHash := [32]byte{} + copy( + permitTypeHash[:], + crypto.Keccak256([]byte("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"))[:32], + ) + + packed, err := args.Pack( + permitTypeHash, + attrs.Sender, + api.Broadcaster(r).ERC20PermitTransfer, + attrs.Amount, + nonce, + attrs.Deadline, + ) + + structHash := crypto.Keccak256(packed) + + //keccak256(abi.encodePacked('x19x01', DOMAIN_SEPARATOR, hashed_args)) + hash := crypto.Keccak256( + []byte("\x19\x01"), + domainSeparator[:], + structHash, + ) + + return hash, nil +} diff --git a/internal/service/router.go b/internal/service/router.go index ca0e688..21412bd 100644 --- a/internal/service/router.go +++ b/internal/service/router.go @@ -3,42 +3,58 @@ package service import ( "context" - "github.com/cosmos/cosmos-sdk/types" "github.com/go-chi/chi" + "github.com/rarimo/evm-airdrop-svc/contracts" "github.com/rarimo/evm-airdrop-svc/internal/config" - "github.com/rarimo/evm-airdrop-svc/internal/service/handlers" + "github.com/rarimo/evm-airdrop-svc/internal/service/api" + "github.com/rarimo/evm-airdrop-svc/internal/service/api/handlers" "gitlab.com/distributed_lab/ape" + "gitlab.com/distributed_lab/logan/v3/errors" ) func Run(ctx context.Context, cfg *config.Config) { - setBech32Prefixes() r := chi.NewRouter() + erc20Permit, err := contracts.NewERC20Permit(cfg.AirdropConfig().TokenAddress, cfg.Broadcaster().RPC) + if err != nil { + panic(errors.Wrap(err, "failed to init erc20 permit transfer contract")) + } + + erc20PermitTransfer, err := contracts.NewERC20TransferWithPermit( + cfg.Broadcaster().ERC20PermitTransfer, cfg.Broadcaster().RPC, + ) + if err != nil { + panic(errors.Wrap(err, "failed to init erc20 permit transfer contract")) + } + r.Use( ape.RecoverMiddleware(cfg.Log()), ape.LoganMiddleware(cfg.Log()), ape.CtxMiddleware( - handlers.CtxLog(cfg.Log()), - handlers.CtxVerifier(cfg.Verifier().ZkVerifier), - handlers.CtxAirdropAmount(cfg.AridropConfig().Amount.String()), - handlers.CtxAirdropParams(cfg.Verifier().Params), + api.CtxLog(cfg.Log()), + api.CtxVerifier(cfg.Verifier().ZkVerifier), + api.CtxAirdropConfig(cfg.AirdropConfig()), + api.CtxAirdropParams(cfg.Verifier().Params), + api.CtxBroadcaster(cfg.Broadcaster()), + api.CtxPriceApiConfig(cfg.PriceApiConfig()), + api.CtxERC20Permit(erc20Permit), + api.CtxERC20PermitTransfer(erc20PermitTransfer), ), handlers.DBCloneMiddleware(cfg.DB()), ) - r.Route("/integrations/evm-airdrop-svc/airdrops", func(r chi.Router) { - r.Post("/", handlers.CreateAirdrop) - r.Get("/{nullifier}", handlers.GetAirdrop) - r.Get("/params", handlers.GetAirdropParams) + r.Route("/integrations/evm-airdrop-svc", func(r chi.Router) { + r.Route("/airdrops", func(r chi.Router) { + r.Post("/", handlers.CreateAirdrop) + r.Get("/{nullifier}", handlers.GetAirdrop) + r.Get("/params", handlers.GetAirdropParams) + }) + + r.Route("/transfer", func(r chi.Router) { + r.Post("/", handlers.SendTransfer) + r.Get("/", handlers.GetTransferParams) + }) }) cfg.Log().Info("Service started") ape.Serve(ctx, r, cfg, ape.ServeOpts{}) } - -func setBech32Prefixes() { - c := types.GetConfig() - c.SetBech32PrefixForAccount("rarimo", "rarimopub") - c.SetBech32PrefixForValidator("rarimovaloper", "rarimovaloperpub") - c.SetBech32PrefixForConsensusNode("rarimovalcons", "rarimovalconspub") - c.Seal() -} diff --git a/resources/model_resource_type.go b/resources/model_resource_type.go index badfc92..3c98861 100644 --- a/resources/model_resource_type.go +++ b/resources/model_resource_type.go @@ -10,4 +10,5 @@ type ResourceType string const ( AIRDROP ResourceType = "airdrop" CREATE_AIRDROP ResourceType = "create_airdrop" + TRANSFER_ERC20 ResourceType = "transfer_erc20" )