diff --git a/README.md b/README.md index 19d9422..7935159 100644 --- a/README.md +++ b/README.md @@ -5,19 +5,21 @@ The official Avalanche Tooling Go SDK library. *** Please note that this SDK is in experimental mode, major changes to the SDK are to be expected in between releases *** -Current version (v0.2.0) currently supports: +Current version (v0.3.0) currently supports: - Create Subnet and Create Blockchain in a Subnet in Fuji / Mainnet. - Create Avalanche Node (Validator / API / Monitoring / Load Test Node) & install all required dependencies (AvalancheGo, gcc, Promtail, Grafana, etc). +- Enable Avalanche nodes to validate Primary Network +- Adding Validators to a Subnet +- Ledger SDK Currently, only stored keys are supported for transaction building and signing, ledger support is coming soon. Future SDK releases will contain the following features (in order of priority): -- Additional Nodes SDK features (Have Avalanche nodes validate a Subnet & Primary Network) -- Additional Subnet SDK features (i.e. Adding Validators to a Subnet, Custom Subnets) +- Additional Nodes SDK features (Devnet) +- Additional Subnet SDK features (Custom Subnets) - Teleporter SDK -- Ledger SDK ## Getting Started @@ -41,6 +43,8 @@ This examples also shows how to create a key pair to pay for transactions, how t object that will be used to build and sign CreateSubnetTx and CreateChainTx and how to commit these transactions on chain. +More examples can be found at examples directory. + ```go package main @@ -74,7 +78,7 @@ func DeploySubnet() { // Key that will be used for paying the transaction fees of CreateSubnetTx and CreateChainTx // NewKeychain will generate a new key pair in the provided path if no .pk file currently // exists in the provided path - keychain, _ := keychain.NewKeychain(network, "KEY_PATH") + keychain, _ := keychain.NewKeychain(network, "KEY_PATH", nil) // In this example, we are using the fee-paying key generated above also as control key // and subnet auth key @@ -91,10 +95,9 @@ func DeploySubnet() { // CreateSubnetTx. // // All keys in subnetAuthKeys have to sign the transaction before the transaction - // can be committed on chain - subnetAuthKeys := controlKeys + subnetAuthKeys := keychain.Addresses().List() threshold := 1 - newSubnet.SetSubnetCreateParams(controlKeys, uint32(threshold)) + newSubnet.SetSubnetControlParams(controlKeys, uint32(threshold)) wallet, _ := wallet.New( context.Background(), @@ -115,7 +118,7 @@ func DeploySubnet() { // we need to wait to allow the transaction to reach other nodes in Fuji time.Sleep(2 * time.Second) - newSubnet.SetBlockchainCreateParams(subnetAuthKeys) + newSubnet.SetSubnetAuthKeys(subnetAuthKeys) // Build and Sign CreateChainTx with our fee paying key (which is also our subnet auth key) deployChainTx, _ := newSubnet.CreateBlockchainTx(wallet) // Commit our CreateChainTx on chain @@ -125,6 +128,111 @@ func DeploySubnet() { fmt.Printf("blockchainID %s \n", blockchainID.String()) } +// Add a validator to Subnet +func AddSubnetValidator() { + // We are using existing Subnet that we have already deployed on Fuji + subnetParams := subnet.SubnetParams{ + GenesisFilePath: "GENESIS_FILE_PATH", + Name: "SUBNET_NAME", + } + + newSubnet, err := subnet.New(&subnetParams) + if err != nil { + panic(err) + } + + subnetID, err := ids.FromString("SUBNET_ID") + if err != nil { + panic(err) + } + + // Genesis doesn't contain the deployed Subnet's SubnetID, we need to first set the Subnet ID + newSubnet.SetSubnetID(subnetID) + + // We are using existing host + node := node.Node{ + // NodeID is Avalanche Node ID of the node + NodeID: "NODE_ID", + // IP address of the node + IP: "NODE_IP_ADDRESS", + // SSH configuration for the node + SSHConfig: node.SSHConfig{ + User: constants.RemoteHostUser, + PrivateKeyPath: "NODE_KEYPAIR_PRIVATE_KEY_PATH", + }, + // Role is the role that we expect the host to be (Validator, API, AWMRelayer, Loadtest or + // Monitor) + Roles: []node.SupportedRole{node.Validator}, + } + + // Here we are assuming that the node is currently validating the Primary Network, which is + // a requirement before the node can start validating a Subnet. + // To have a node validate the Primary Network, call node.ValidatePrimaryNetwork + // Now we are calling the node to start tracking the Subnet + subnetIDsToValidate := []string{newSubnet.SubnetID.String()} + if err := node.SyncSubnets(subnetIDsToValidate); err != nil { + panic(err) + } + + // Node is now tracking the Subnet + + // Key that will be used for paying the transaction fees of Subnet AddValidator Tx + // + // In our example, this Key is also the control Key to the Subnet, so we are going to use + // this key to also sign the Subnet AddValidator tx + network := avalanche.FujiNetwork() + keychain, err := keychain.NewKeychain(network, "PRIVATE_KEY_FILEPATH", nil) + if err != nil { + panic(err) + } + + wallet, err := wallet.New( + context.Background(), + &primary.WalletConfig{ + URI: network.Endpoint, + AVAXKeychain: keychain.Keychain, + EthKeychain: secp256k1fx.NewKeychain(), + PChainTxsToFetch: set.Of(subnetID), + }, + ) + if err != nil { + panic(err) + } + + nodeID, err := ids.NodeIDFromString(node.NodeID) + if err != nil { + panic(err) + } + + validatorParams := validator.SubnetValidatorParams{ + NodeID: nodeID, + // Validate Subnet for 48 hours + Duration: 48 * time.Hour, + Weight: 20, + } + + // We need to set Subnet Auth Keys for this transaction since Subnet AddValidator is + // a Subnet-changing transaction + // + // In this example, the example Subnet was created with only 1 key as control key with a threshold of 1 + // and the control key is the key contained in the keychain object, so we are going to use the + // key contained in the keychain object as the Subnet Auth Key for Subnet AddValidator tx + subnetAuthKeys := keychain.Addresses().List() + newSubnet.SetSubnetAuthKeys(subnetAuthKeys) + + addValidatorTx, err := newSubnet.AddValidator(wallet, validatorParams) + if err != nil { + panic(err) + } + + // Since it has the required signatures, we will now commit the transaction on chain + txID, err := newSubnet.Commit(*addValidatorTx, wallet, true) + if err != nil { + panic(err) + } + fmt.Printf("obtained tx id %s", txID.String()) +} + func getDefaultSubnetEVMGenesis() subnet.SubnetParams { allocation := core.GenesisAlloc{} defaultAmount, _ := new(big.Int).SetString(vm.DefaultEvmAirdropAmount, 10) @@ -153,6 +261,8 @@ centralized Grafana Dashboard where you can view metrics relevant to any Validat the monitoring node is linked to as well as a centralized logs for the X/P/C Chain and Subnet logs for the Validator & API nodes. An example on how the dashboard and logs look like can be found at https://docs.avax.network/tooling/cli-create-nodes/create-a-validator-aws +More examples can be found at examples directory. + ```go package main @@ -278,4 +388,61 @@ func CreateNodes() { panic(err) } } + +func ValidatePrimaryNetwork() { + // We are using existing host + node := node.Node{ + // NodeID is Avalanche Node ID of the node + NodeID: "NODE_ID", + // IP address of the node + IP: "NODE_IP_ADDRESS", + // SSH configuration for the node + SSHConfig: node.SSHConfig{ + User: constants.RemoteHostUser, + PrivateKeyPath: "NODE_KEYPAIR_PRIVATE_KEY_PATH", + }, + // Role of the node can be Validator, API, AWMRelayer, Loadtest, or Monitor + Roles: []node.SupportedRole{node.Validator}, + } + + nodeID, err := ids.NodeIDFromString(node.NodeID) + if err != nil { + panic(err) + } + + validatorParams := validator.PrimaryNetworkValidatorParams{ + NodeID: nodeID, + // Validate Primary Network for 48 hours + Duration: 48 * time.Hour, + // Stake 2 AVAX + StakeAmount: 2 * units.Avax, + } + + // Key that will be used for paying the transaction fee of AddValidator Tx + network := avalanche.FujiNetwork() + keychain, err := keychain.NewKeychain(network, "PRIVATE_KEY_FILEPATH", nil) + if err != nil { + panic(err) + } + + wallet, err := wallet.New( + context.Background(), + &primary.WalletConfig{ + URI: network.Endpoint, + AVAXKeychain: keychain.Keychain, + EthKeychain: secp256k1fx.NewKeychain(), + PChainTxsToFetch: nil, + }, + ) + if err != nil { + panic(err) + } + + txID, err := node.ValidatePrimaryNetwork(avalanche.FujiNetwork(), validatorParams, wallet) + if err != nil { + panic(err) + } + fmt.Printf("obtained tx id %s", txID.String()) +} + ``` diff --git a/avalanche/log.go b/avalanche/log.go index db44b8a..0b0b42a 100644 --- a/avalanche/log.go +++ b/avalanche/log.go @@ -9,10 +9,6 @@ import ( "os" ) -// -// Public constants -// - const ( // LevelNull sets a logger to show no messages at all. LevelNull Level = 0 diff --git a/avalanche/network.go b/avalanche/network.go index 1dc0ee2..f227383 100644 --- a/avalanche/network.go +++ b/avalanche/network.go @@ -7,8 +7,11 @@ import ( "fmt" "strings" + "github.com/ava-labs/avalanche-tooling-sdk-go/utils" "github.com/ava-labs/avalanchego/genesis" + "github.com/ava-labs/avalanchego/ids" "github.com/ava-labs/avalanchego/utils/constants" + "github.com/ava-labs/avalanchego/vms/platformvm" ) type NetworkKind int64 @@ -104,3 +107,14 @@ func (n Network) BlockchainWSEndpoint(blockchainID string) string { trimmedURI = strings.TrimPrefix(trimmedURI, "https://") return fmt.Sprintf("ws://%s/ext/bc/%s/ws", trimmedURI, blockchainID) } + +func (n Network) GetMinStakingAmount() (uint64, error) { + pClient := platformvm.NewClient(n.Endpoint) + ctx, cancel := utils.GetAPIContext() + defer cancel() + minValStake, _, err := pClient.GetMinStake(ctx, ids.Empty) + if err != nil { + return 0, err + } + return minValStake, nil +} diff --git a/cloud/gcp/gcp.go b/cloud/gcp/gcp.go index b57ee3a..584bec0 100644 --- a/cloud/gcp/gcp.go +++ b/cloud/gcp/gcp.go @@ -238,7 +238,7 @@ func (c *GcpCloud) SetupInstances( } instances := make([]*compute.Instance, numNodes) instancesChan := make(chan *compute.Instance, numNodes) - sshKey := fmt.Sprintf("%s:%s", constants.AnsibleSSHUser, strings.TrimSuffix(sshPublicKey, "\n")) + sshKey := fmt.Sprintf("%s:%s", constants.RemoteHostUser, strings.TrimSuffix(sshPublicKey, "\n")) automaticRestart := true instancePrefix := utils.RandomString(5) diff --git a/constants/constants.go b/constants/constants.go index 2c91852..adba854 100644 --- a/constants/constants.go +++ b/constants/constants.go @@ -36,23 +36,24 @@ const ( AvalanchegoMachineMetricsPort = 9100 AvalanchegoLoadTestPort = 8082 + // http + APIRequestTimeout = 30 * time.Second + APIRequestLargeTimeout = 2 * time.Minute + // ssh SSHSleepBetweenChecks = 1 * time.Second SSHLongRunningScriptTimeout = 10 * time.Minute SSHFileOpsTimeout = 100 * time.Second SSHPOSTTimeout = 10 * time.Second SSHScriptTimeout = 2 * time.Minute - AnsibleSSHUser = "ubuntu" + RemoteHostUser = "ubuntu" // node - CloudNodeCLIConfigBasePath = "/home/ubuntu/.avalanche-cli/" - CloudNodeConfigBasePath = "/home/ubuntu/.avalanchego/" - CloudNodeSubnetVMBinaryPath = "/home/ubuntu/.avalanchego/plugins/%s" - CloudNodeStakingPath = "/home/ubuntu/.avalanchego/staking/" - CloudNodeConfigPath = "/home/ubuntu/.avalanchego/configs/" - ServicesDir = "services" - DashboardsDir = "dashboards" - + CloudNodeCLIConfigBasePath = "/home/ubuntu/.avalanche-cli/" + CloudNodeStakingPath = "/home/ubuntu/.avalanchego/staking/" + CloudNodeConfigPath = "/home/ubuntu/.avalanchego/configs/" + ServicesDir = "services" + DashboardsDir = "dashboards" // services ServiceAvalanchego = "avalanchego" ServicePromtail = "promtail" diff --git a/evm/contract.go b/evm/contract.go index 217f2f9..08a9d7b 100644 --- a/evm/contract.go +++ b/evm/contract.go @@ -17,29 +17,36 @@ import ( var ErrFailedReceiptStatus = fmt.Errorf("failed receipt status") -func removeSurroundingParenthesis(s string) (string, error) { - s = strings.TrimSpace(s) - if len(s) > 0 { - if string(s[0]) != "(" || string(s[len(s)-1]) != ")" { - return "", fmt.Errorf("expected esp %q to be surrounded by parenthesis", s) - } - s = s[1 : len(s)-1] - } - return s, nil -} +type SignatureKind int64 -func removeSurroundingBrackets(s string) (string, error) { - s = strings.TrimSpace(s) - if len(s) > 0 { - if string(s[0]) != "[" || string(s[len(s)-1]) != "]" { - return "", fmt.Errorf("expected esp %q to be surrounded by parenthesis", s) - } - s = s[1 : len(s)-1] - } - return s, nil -} +const ( + Constructor SignatureKind = iota + Method + Event +) + +type PaymentKind int64 + +const ( + View PaymentKind = iota + Payable + NonPayable +) -func getWords(s string) []string { +// splitTypes splits a string of comma separated type esps into a slice of type esps: +// - considering a list of types esps surrounded by (nested) parenthesis as one struct type esp +// - considering a type esp surrounded by brackets as one array type esp +// note: it just parses the first level of type esps: parsing of nested subtypes is called recursively by getABIMaps +// +// ie: +// "bool,int" maps to ["bool", "int"] - 2 primitive types bool and int +// "(bool,int)" maps to ["(bool,int)"] - 1 struct type (bool,int) +// "(bool,int),bool" maps to ["(bool,int)","bool"] - 1 struct type (bool,int) + 1 primitive type bool +// "[(bool,int)],[bool]" maps to ["[(bool,int)]","[bool]"] - 1 array of structs (bool,int) type + 1 array of bools +// +// TODO: manage all recursion here, returning a list of types, not strings, where compound types are supported +// (so, returning a tree) +func splitTypes(s string) []string { words := []string{} word := "" parenthesisCount := 0 @@ -92,60 +99,66 @@ func getWords(s string) []string { return words } -func getMap( +// for a list of strings that specifie types, it generates +// a list of ethereum ABI descriptions, +// compatible with the subnet-evm bind library +// +// note: as, for structs, bind calls check ABI field names against golang struct field +// names, input [values] are passed, so as to get appropriate ABI names +func getABIMaps( types []string, - params interface{}, + values interface{}, ) ([]map[string]interface{}, error) { r := []map[string]interface{}{} for i, t := range types { var ( - param interface{} + value interface{} name string structName string ) - rt := reflect.ValueOf(params) + rt := reflect.ValueOf(values) if rt.Kind() == reflect.Ptr { rt = rt.Elem() } if rt.Kind() == reflect.Slice { if rt.Len() != len(types) { if rt.Len() == 1 { - return getMap(types, rt.Index(0).Interface()) + return getABIMaps(types, rt.Index(0).Interface()) } else { return nil, fmt.Errorf( - "inconsistency in slice len between method esp %q and given params %#v: expected %d got %d", + "inconsistency in slice len between method esp %q and given values %#v: expected %d got %d", types, - params, + values, len(types), rt.Len(), ) } } - param = rt.Index(i).Interface() + value = rt.Index(i).Interface() } else if rt.Kind() == reflect.Struct { if rt.NumField() < len(types) { return nil, fmt.Errorf( - "inconsistency in struct len between method esp %q and given params %#v: expected %d got %d", + "inconsistency in struct len between method esp %q and given values %#v: expected %d got %d", types, - params, + values, len(types), rt.NumField(), ) } name = rt.Type().Field(i).Name structName = rt.Type().Field(i).Type.Name() - param = rt.Field(i).Interface() + value = rt.Field(i).Interface() } m := map[string]interface{}{} switch { case string(t[0]) == "(": // struct type var err error - t, err = removeSurroundingParenthesis(t) + t, err = utils.RemoveSurrounding(t, "(", ")") if err != nil { return nil, err } - m["components"], err = getMap(getWords(t), param) + m["components"], err = getABIMaps(splitTypes(t), value) if err != nil { return nil, err } @@ -158,22 +171,22 @@ func getMap( m["name"] = name case string(t[0]) == "[": var err error - t, err = removeSurroundingBrackets(t) + t, err = utils.RemoveSurrounding(t, "[", "]") if err != nil { return nil, err } if string(t[0]) == "(" { - t, err = removeSurroundingParenthesis(t) + t, err = utils.RemoveSurrounding(t, "(", ")") if err != nil { return nil, err } - rt := reflect.ValueOf(param) + rt := reflect.ValueOf(value) if rt.Kind() != reflect.Slice { - return nil, fmt.Errorf("expected param for field %d of esp %q to be an slice", i, types) + return nil, fmt.Errorf("expected value for field %d of esp %q to be an slice", i, types) } - param = reflect.Zero(rt.Type().Elem()).Interface() + value = reflect.Zero(rt.Type().Elem()).Interface() structName = rt.Type().Elem().Name() - m["components"], err = getMap(getWords(t), param) + m["components"], err = getABIMaps(splitTypes(t), value) if err != nil { return nil, err } @@ -199,82 +212,94 @@ func getMap( return r, nil } -func ParseEsp( - esp string, +// ParseMethodSignature parses method/event [signature] +// of format "name(inputs)->(outputs)", where: +// - name is optional +// - ->(outputs) is optional +// - inputs and outputs are a comma separated list of type esps that follow the +// format of splitTypes +// +// generates a ethereum ABI especification +// that can be used in the subnet-evm bind library. +// +// note: as, for structs, bind calls check ABI field names against golang struct field +// names, input [values] are passed, so as to get appropriate ABI names +func ParseMethodSignature( + signature string, + kind SignatureKind, indexedFields []int, - constructor bool, - event bool, - paid bool, - view bool, - params ...interface{}, + paymentKind PaymentKind, + values ...interface{}, ) (string, string, error) { - index := strings.Index(esp, "(") - if index == -1 { - return esp, "", nil - } - name := esp[:index] - types := esp[index:] - inputs := "" - outputs := "" - index = strings.Index(types, "->") - if index == -1 { - inputs = types + inputsOutputsIndex := strings.Index(signature, "(") + if inputsOutputsIndex == -1 { + return signature, "", nil + } + name := signature[:inputsOutputsIndex] + typesSignature := signature[inputsOutputsIndex:] + inputTypesSignature := "" + outputTypesSignature := "" + arrowIndex := strings.Index(typesSignature, "->") + if arrowIndex == -1 { + inputTypesSignature = typesSignature } else { - inputs = types[:index] - outputs = types[index+2:] + inputTypesSignature = typesSignature[:arrowIndex] + outputTypesSignature = typesSignature[arrowIndex+2:] } var err error - inputs, err = removeSurroundingParenthesis(inputs) + inputTypesSignature, err = utils.RemoveSurrounding(inputTypesSignature, "(", ")") if err != nil { return "", "", err } - outputs, err = removeSurroundingParenthesis(outputs) + outputTypesSignature, err = utils.RemoveSurrounding(outputTypesSignature, "(", ")") if err != nil { return "", "", err } - inputTypes := getWords(inputs) - outputTypes := getWords(outputs) - inputsMaps, err := getMap(inputTypes, params) + inputTypes := splitTypes(inputTypesSignature) + outputTypes := splitTypes(outputTypesSignature) + inputsMaps, err := getABIMaps(inputTypes, values) if err != nil { return "", "", err } - outputsMaps, err := getMap(outputTypes, nil) + outputsMaps, err := getABIMaps(outputTypes, nil) if err != nil { return "", "", err } - if event { + abiMap := map[string]interface{}{ + "inputs": inputsMaps, + } + switch kind { + case Constructor: + abiMap["type"] = "constructor" + abiMap["stateMutability"] = "nonpayable" + case Method: + abiMap["type"] = "function" + abiMap["name"] = name + abiMap["outputs"] = outputsMaps + switch paymentKind { + case Payable: + abiMap["stateMutability"] = "payable" + case View: + abiMap["stateMutability"] = "view" + case NonPayable: + abiMap["stateMutability"] = "nonpayable" + default: + return "", "", fmt.Errorf("unsupported payment kind %d", paymentKind) + } + case Event: + abiMap["type"] = "event" + abiMap["name"] = name for i := range inputsMaps { if utils.Belongs(indexedFields, i) { inputsMaps[i]["indexed"] = true } } - } - abiMap := []map[string]interface{}{ - { - "inputs": inputsMaps, - }, - } - switch { - case paid: - abiMap[0]["stateMutability"] = "payable" - case view: - abiMap[0]["stateMutability"] = "view" - default: - abiMap[0]["stateMutability"] = "nonpayable" - } - switch { - case constructor: - abiMap[0]["type"] = "constructor" - case event: - abiMap[0]["type"] = "event" - abiMap[0]["name"] = name - delete(abiMap[0], "stateMutability") default: - abiMap[0]["type"] = "function" - abiMap[0]["outputs"] = outputsMaps - abiMap[0]["name"] = name + return "", "", fmt.Errorf("unsupported signature kind %d", kind) } - abiBytes, err := json.MarshalIndent(abiMap, "", " ") + abiMap["inputs"] = inputsMaps + abiSlice := []map[string]interface{}{abiMap} + abiBytes, err := json.MarshalIndent(abiSlice, "", " ") if err != nil { return "", "", err } @@ -286,10 +311,14 @@ func TxToMethod( privateKey string, contractAddress common.Address, payment *big.Int, - methodEsp string, + methodSignature string, params ...interface{}, ) (*types.Transaction, *types.Receipt, error) { - methodName, methodABI, err := ParseEsp(methodEsp, nil, false, false, payment != nil, false, params...) + paymentKind := NonPayable + if payment != nil { + paymentKind = Payable + } + methodName, methodABI, err := ParseMethodSignature(methodSignature, Method, nil, paymentKind, params...) if err != nil { return nil, nil, err } @@ -330,7 +359,7 @@ func CallToMethod( methodEsp string, params ...interface{}, ) ([]interface{}, error) { - methodName, methodABI, err := ParseEsp(methodEsp, nil, false, false, false, true, params...) + methodName, methodABI, err := ParseMethodSignature(methodEsp, Method, nil, View, params...) if err != nil { return nil, err } @@ -362,7 +391,7 @@ func DeployContract( methodEsp string, params ...interface{}, ) (common.Address, error) { - _, methodABI, err := ParseEsp(methodEsp, nil, true, false, false, false, params...) + _, methodABI, err := ParseMethodSignature(methodEsp, Constructor, nil, NonPayable, params...) if err != nil { return common.Address{}, err } @@ -402,7 +431,7 @@ func UnpackLog( log types.Log, event interface{}, ) error { - eventName, eventABI, err := ParseEsp(eventEsp, indexedFields, false, true, false, false, event) + eventName, eventABI, err := ParseMethodSignature(eventEsp, Event, indexedFields, NonPayable, event) if err != nil { return err } diff --git a/evm/contract_test.go b/evm/contract_test.go new file mode 100644 index 0000000..3202987 --- /dev/null +++ b/evm/contract_test.go @@ -0,0 +1,281 @@ +// Copyright (C) 2022, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. +package evm + +import ( + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +func TestSplitTypes(t *testing.T) { + type TestEsp struct { + desc string + input string + expected []string + } + for _, testEsp := range []TestEsp{ + { + desc: "primitive type bool", + input: "bool", + expected: []string{ + "bool", + }, + }, + { + desc: "struct that contains a bool", + input: "(bool)", + expected: []string{ + "(bool)", + }, + }, + { + desc: "array of bools", + input: "[bool]", + expected: []string{ + "[bool]", + }, + }, + { + desc: "array of structs that contain a bool", + input: "[(bool)]", + expected: []string{ + "[(bool)]", + }, + }, + { + desc: "struct that contains an address and a uint256", + input: "(address, uint256)", + expected: []string{ + "(address, uint256)", + }, + }, + { + desc: "array of structs that contain an address and a uint256", + input: "[(address, uint256)]", + expected: []string{ + "[(address, uint256)]", + }, + }, + { + desc: "list of types address and uint256", + input: "address, uint256", + expected: []string{ + "address", + "uint256", + }, + }, + { + desc: "list of types with a simple struct and a uint256", + input: "(bytes32, uint256), uint256", + expected: []string{ + "(bytes32, uint256)", + "uint256", + }, + }, + { + desc: "list of types with a nested struct and a bool", + input: "(bytes32, address, (address, uint256), uint256, [address], bytes), bool", + expected: []string{ + "(bytes32, address, (address, uint256), uint256, [address], bytes)", + "bool", + }, + }, + { + desc: "list of types that includes a simple struct and an array", + input: "bytes32, address, (address, uint256), uint256, [address], bytes", + expected: []string{ + "bytes32", + "address", + "(address, uint256)", + "uint256", + "[address]", + "bytes", + }, + }, + } { + output := splitTypes(testEsp.input) + require.Equal(t, testEsp.expected, output, testEsp.desc) + } +} + +func TestGetABIMaps(t *testing.T) { + type TestEsp struct { + desc string + input string + values interface{} + expected []map[string]interface{} + } + type TeleporterFeeInfo struct { + FeeTokenAddress common.Address + Amount *big.Int + } + type TeleporterMessageInput struct { + DestinationBlockchainID [32]byte + DestinationAddress common.Address + FeeInfo TeleporterFeeInfo + RequiredGasLimit *big.Int + AllowedRelayerAddresses []common.Address + Message []byte + } + type BoolInput struct { + FieldName bool + } + for _, testEsp := range []TestEsp{ + { + desc: "primitive type bool", + input: "bool", + values: []interface{}{ + true, + }, + expected: []map[string]interface{}{ + { + "internalType": "bool", + "name": "", + "type": "bool", + }, + }, + }, + { + desc: "slice of two primitives", + input: "bool,int", + values: []interface{}{ + true, + 5, + }, + expected: []map[string]interface{}{ + { + "internalType": "bool", + "name": "", + "type": "bool", + }, + { + "internalType": "int", + "name": "", + "type": "int", + }, + }, + }, + { + desc: "struct of bool", + input: "(bool)", + values: []interface{}{ + BoolInput{}, + }, + expected: []map[string]interface{}{ + { + "internalType": "tuple", + "name": "", + "type": "tuple", + "components": []map[string]interface{}{ + { + "internalType": "bool", + "name": "FieldName", + "type": "bool", + }, + }, + }, + }, + }, + { + desc: "array of bool", + input: "[bool]", + values: []interface{}{ + BoolInput{}, + }, + expected: []map[string]interface{}{ + { + "internalType": "bool[]", + "name": "", + "type": "bool[]", + }, + }, + }, + { + desc: "array of struct of bool", + input: "[(bool)]", + values: []interface{}{ + []BoolInput{}, + }, + expected: []map[string]interface{}{ + { + "internalType": "struct BoolInput[]", + "name": "", + "type": "tuple[]", + "components": []map[string]interface{}{ + { + "internalType": "bool", + "name": "FieldName", + "type": "bool", + }, + }, + }, + }, + }, + { + desc: "sendCrossChainMessage input", + input: "(bytes32, address, (address, uint256), uint256, [address], bytes)", + values: []interface{}{ + TeleporterMessageInput{}, + }, + expected: []map[string]interface{}{ + { + "internalType": "tuple", + "name": "", + "type": "tuple", + "components": []map[string]interface{}{ + { + "internalType": "bytes32", + "name": "DestinationBlockchainID", + "type": "bytes32", + }, + { + "internalType": "address", + "name": "DestinationAddress", + "type": "address", + }, + { + "components": []map[string]interface{}{ + { + "internalType": "address", + "name": "FeeTokenAddress", + "type": "address", + }, + { + "internalType": "uint256", + "name": "Amount", + "type": "uint256", + }, + }, + "internalType": "struct TeleporterFeeInfo", + "name": "FeeInfo", + "type": "tuple", + }, + { + "internalType": "uint256", + "name": "RequiredGasLimit", + "type": "uint256", + }, + { + "internalType": "address[]", + "name": "AllowedRelayerAddresses", + "type": "address[]", + }, + { + "internalType": "bytes", + "name": "Message", + "type": "bytes", + }, + }, + }, + }, + }, + } { + output := splitTypes(testEsp.input) + maps, err := getABIMaps(output, testEsp.values) + require.NoError(t, err) + require.Equal(t, testEsp.expected, maps) + } +} diff --git a/evm/evm.go b/evm/evm.go index 2538735..03ff212 100644 --- a/evm/evm.go +++ b/evm/evm.go @@ -3,10 +3,11 @@ package evm import ( + "context" "fmt" "math/big" - "time" + "github.com/ava-labs/avalanche-tooling-sdk-go/constants" "github.com/ava-labs/avalanche-tooling-sdk-go/utils" "github.com/ava-labs/subnet-evm/accounts/abi/bind" "github.com/ava-labs/subnet-evm/core/types" @@ -21,7 +22,6 @@ const ( MaxPriorityFeePerGas = 2500000000 // 2.5 gwei NativeTransferGas uint64 = 21_000 repeatsOnFailure = 3 - sleepBetweenRepeats = 1 * time.Second ) func ContractAlreadyDeployed( @@ -40,26 +40,12 @@ func GetContractBytecode( contractAddressStr string, ) ([]byte, error) { contractAddress := common.HexToAddress(contractAddressStr) - var ( - code []byte - err error + return utils.Retry( + func(ctx context.Context) ([]byte, error) { return client.CodeAt(ctx, contractAddress, nil) }, + constants.APIRequestLargeTimeout, + repeatsOnFailure, + fmt.Sprintf("failure obtaining code for %s on %#v", contractAddressStr, client), ) - for i := 0; i < repeatsOnFailure; i++ { - ctx, cancel := utils.GetAPILargeContext() - defer cancel() - code, err = client.CodeAt(ctx, contractAddress, nil) - if err == nil { - break - } - err = fmt.Errorf( - "failure obtaining code for %s on %#v: %w", - contractAddressStr, - client, - err, - ) - time.Sleep(sleepBetweenRepeats) - } - return code, err } func GetAddressBalance( @@ -67,21 +53,12 @@ func GetAddressBalance( addressStr string, ) (*big.Int, error) { address := common.HexToAddress(addressStr) - var ( - balance *big.Int - err error + return utils.Retry( + func(ctx context.Context) (*big.Int, error) { return client.BalanceAt(ctx, address, nil) }, + constants.APIRequestLargeTimeout, + repeatsOnFailure, + fmt.Sprintf("failure obtaining balance for %s on %#v", addressStr, client), ) - for i := 0; i < repeatsOnFailure; i++ { - ctx, cancel := utils.GetAPILargeContext() - defer cancel() - balance, err = client.BalanceAt(ctx, address, nil) - if err == nil { - break - } - err = fmt.Errorf("failure obtaining balance for %s on %#v: %w", addressStr, client, err) - time.Sleep(sleepBetweenRepeats) - } - return balance, err } // Returns the gasFeeCap, gasTipCap, and nonce the be used when constructing a transaction from address @@ -111,61 +88,34 @@ func NonceAt( addressStr string, ) (uint64, error) { address := common.HexToAddress(addressStr) - var ( - nonce uint64 - err error + return utils.Retry( + func(ctx context.Context) (uint64, error) { return client.NonceAt(ctx, address, nil) }, + constants.APIRequestLargeTimeout, + repeatsOnFailure, + fmt.Sprintf("failure obtaining nonce for %s on %#v", addressStr, client), ) - for i := 0; i < repeatsOnFailure; i++ { - ctx, cancel := utils.GetAPILargeContext() - defer cancel() - nonce, err = client.NonceAt(ctx, address, nil) - if err == nil { - break - } - err = fmt.Errorf("failure obtaining nonce for %s on %#v: %w", addressStr, client, err) - time.Sleep(sleepBetweenRepeats) - } - return nonce, err } func SuggestGasTipCap( client ethclient.Client, ) (*big.Int, error) { - var ( - gasTipCap *big.Int - err error + return utils.Retry( + func(ctx context.Context) (*big.Int, error) { return client.SuggestGasTipCap(ctx) }, + constants.APIRequestLargeTimeout, + repeatsOnFailure, + fmt.Sprintf("failure obtaining gas tip cap on %#v", client), ) - for i := 0; i < repeatsOnFailure; i++ { - ctx, cancel := utils.GetAPILargeContext() - defer cancel() - gasTipCap, err = client.SuggestGasTipCap(ctx) - if err == nil { - break - } - err = fmt.Errorf("failure obtaining gas tip cap on %#v: %w", client, err) - time.Sleep(sleepBetweenRepeats) - } - return gasTipCap, err } func EstimateBaseFee( client ethclient.Client, ) (*big.Int, error) { - var ( - baseFee *big.Int - err error + return utils.Retry( + func(ctx context.Context) (*big.Int, error) { return client.EstimateBaseFee(ctx) }, + constants.APIRequestLargeTimeout, + repeatsOnFailure, + fmt.Sprintf("failure estimating base fee on %#v", client), ) - for i := 0; i < repeatsOnFailure; i++ { - ctx, cancel := utils.GetAPILargeContext() - defer cancel() - baseFee, err = client.EstimateBaseFee(ctx) - if err == nil { - break - } - err = fmt.Errorf("failure estimating base fee on %#v: %w", client, err) - time.Sleep(sleepBetweenRepeats) - } - return baseFee, err } func SetMinBalance( @@ -261,54 +211,31 @@ func SendTransaction( client ethclient.Client, tx *types.Transaction, ) error { - var err error - for i := 0; i < repeatsOnFailure; i++ { - ctx, cancel := utils.GetAPILargeContext() - defer cancel() - err = client.SendTransaction(ctx, tx) - if err == nil { - break - } - err = fmt.Errorf("failure sending transaction %#v to %#v: %w", tx, client, err) - time.Sleep(sleepBetweenRepeats) - } + _, err := utils.Retry( + func(ctx context.Context) (interface{}, error) { return nil, client.SendTransaction(ctx, tx) }, + constants.APIRequestLargeTimeout, + repeatsOnFailure, + fmt.Sprintf("failure sending transaction %#v to %#v", tx, client), + ) return err } func GetClient(rpcURL string) (ethclient.Client, error) { - var ( - client ethclient.Client - err error + return utils.Retry( + func(ctx context.Context) (ethclient.Client, error) { return ethclient.DialContext(ctx, rpcURL) }, + constants.APIRequestLargeTimeout, + repeatsOnFailure, + fmt.Sprintf("failure connecting to %s", rpcURL), ) - for i := 0; i < repeatsOnFailure; i++ { - ctx, cancel := utils.GetAPILargeContext() - defer cancel() - client, err = ethclient.DialContext(ctx, rpcURL) - if err == nil { - break - } - err = fmt.Errorf("failure connecting to %s: %w", rpcURL, err) - time.Sleep(sleepBetweenRepeats) - } - return client, err } func GetChainID(client ethclient.Client) (*big.Int, error) { - var ( - chainID *big.Int - err error + return utils.Retry( + func(ctx context.Context) (*big.Int, error) { return client.ChainID(ctx) }, + constants.APIRequestLargeTimeout, + repeatsOnFailure, + fmt.Sprintf("failure getting chain id from client %#v", client), ) - for i := 0; i < repeatsOnFailure; i++ { - ctx, cancel := utils.GetAPILargeContext() - defer cancel() - chainID, err = client.ChainID(ctx) - if err == nil { - break - } - err = fmt.Errorf("failure getting chain id from client %#v: %w", client, err) - time.Sleep(sleepBetweenRepeats) - } - return chainID, err } func GetTxOptsWithSigner( @@ -330,21 +257,15 @@ func WaitForTransaction( client ethclient.Client, tx *types.Transaction, ) (*types.Receipt, bool, error) { - var ( - err error - receipt *types.Receipt - success bool + receipt, err := utils.Retry( + func(ctx context.Context) (*types.Receipt, error) { return bind.WaitMined(ctx, client, tx) }, + constants.APIRequestLargeTimeout, + repeatsOnFailure, + fmt.Sprintf("failure waiting for tx %#v on client %#v", tx, client), ) - for i := 0; i < repeatsOnFailure; i++ { - ctx, cancel := utils.GetAPILargeContext() - defer cancel() - receipt, err = bind.WaitMined(ctx, client, tx) - if err == nil { - success = receipt.Status == types.ReceiptStatusSuccessful - break - } - err = fmt.Errorf("failure waiting for tx %#v on client %#v: %w", tx, client, err) - time.Sleep(sleepBetweenRepeats) + var success bool + if receipt != nil { + success = receipt.Status == types.ReceiptStatusSuccessful } return receipt, success, err } @@ -366,47 +287,33 @@ func GetEventFromLogs[T any](logs []*types.Log, parser func(log types.Log) (T, e } func GetRPCClient(rpcURL string) (*rpc.Client, error) { - var ( - client *rpc.Client - err error + return utils.Retry( + func(ctx context.Context) (*rpc.Client, error) { return rpc.DialContext(ctx, rpcURL) }, + constants.APIRequestLargeTimeout, + repeatsOnFailure, + fmt.Sprintf("failure connecting to %s", rpcURL), ) - for i := 0; i < repeatsOnFailure; i++ { - ctx, cancel := utils.GetAPILargeContext() - defer cancel() - client, err = rpc.DialContext(ctx, rpcURL) - if err == nil { - break - } - err = fmt.Errorf("failure connecting to rpc client on %s: %w", rpcURL, err) - time.Sleep(sleepBetweenRepeats) - } - return client, err } func DebugTraceTransaction( client *rpc.Client, txID string, ) (map[string]interface{}, error) { - var ( - err error - trace map[string]interface{} + var trace map[string]interface{} + _, err := utils.Retry( + func(ctx context.Context) (interface{}, error) { + return nil, client.CallContext( + ctx, + &trace, + "debug_traceTransaction", + txID, + map[string]string{"tracer": "callTracer"}, + ) + }, + constants.APIRequestLargeTimeout, + repeatsOnFailure, + fmt.Sprintf("failure tracing tx %s for client %#v", txID, client), ) - for i := 0; i < repeatsOnFailure; i++ { - ctx, cancel := utils.GetAPILargeContext() - defer cancel() - err = client.CallContext( - ctx, - &trace, - "debug_traceTransaction", - txID, - map[string]string{"tracer": "callTracer"}, - ) - if err == nil { - break - } - err = fmt.Errorf("failure tracing tx %s for client %#v: %w", txID, client, err) - time.Sleep(sleepBetweenRepeats) - } return trace, err } diff --git a/examples/node.go b/examples/node.go index c500619..ed1a457 100644 --- a/examples/node.go +++ b/examples/node.go @@ -102,15 +102,6 @@ func CreateNodes() { } } - // examle of how to reconfigure the created nodes to track a subnet - subnetIDsToValidate := []string{"xxxxxxxxxxxxxxxxxxxyyyyyyyyyyyyyyyzzzzzzzzzzzzzzz"} - for _, h := range hosts { - fmt.Println("Reconfiguring node %s to track subnet %s", h.NodeID, subnetIDsToValidate) - if err := h.SyncSubnets(subnetIDsToValidate); err != nil { - panic(err) - } - } - // Create a monitoring node. // Monitoring node enables you to have a centralized Grafana Dashboard where you can view // metrics relevant to any Validator & API nodes that the monitoring node is linked to as well diff --git a/examples/node_validate_primary.go b/examples/node_validate_primary.go new file mode 100644 index 0000000..449c91e --- /dev/null +++ b/examples/node_validate_primary.go @@ -0,0 +1,78 @@ +// Copyright (C) 2024, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package examples + +import ( + "context" + "fmt" + "time" + + "github.com/ava-labs/avalanche-tooling-sdk-go/constants" + "github.com/ava-labs/avalanche-tooling-sdk-go/keychain" + "github.com/ava-labs/avalanche-tooling-sdk-go/validator" + "github.com/ava-labs/avalanche-tooling-sdk-go/wallet" + "github.com/ava-labs/avalanchego/ids" + "github.com/ava-labs/avalanchego/utils/units" + "github.com/ava-labs/avalanchego/vms/secp256k1fx" + "github.com/ava-labs/avalanchego/wallet/subnet/primary" + + "github.com/ava-labs/avalanche-tooling-sdk-go/avalanche" + "github.com/ava-labs/avalanche-tooling-sdk-go/node" +) + +func ValidatePrimaryNetwork() { + // We are using existing host + node := node.Node{ + // NodeID is Avalanche Node ID of the node + NodeID: "NODE_ID", + // IP address of the node + IP: "NODE_IP_ADDRESS", + // SSH configuration for the node + SSHConfig: node.SSHConfig{ + User: constants.RemoteHostUser, + PrivateKeyPath: "NODE_KEYPAIR_PRIVATE_KEY_PATH", + }, + // Role of the node can be Validator, API, AWMRelayer, Loadtest, or Monitor + Roles: []node.SupportedRole{node.Validator}, + } + + nodeID, err := ids.NodeIDFromString(node.NodeID) + if err != nil { + panic(err) + } + + validatorParams := validator.PrimaryNetworkValidatorParams{ + NodeID: nodeID, + // Validate Primary Network for 48 hours + Duration: 48 * time.Hour, + // Stake 2 AVAX + StakeAmount: 2 * units.Avax, + } + + // Key that will be used for paying the transaction fee of AddValidator Tx + network := avalanche.FujiNetwork() + keychain, err := keychain.NewKeychain(network, "PRIVATE_KEY_FILEPATH", nil) + if err != nil { + panic(err) + } + + wallet, err := wallet.New( + context.Background(), + &primary.WalletConfig{ + URI: network.Endpoint, + AVAXKeychain: keychain.Keychain, + EthKeychain: secp256k1fx.NewKeychain(), + PChainTxsToFetch: nil, + }, + ) + if err != nil { + panic(err) + } + + txID, err := node.ValidatePrimaryNetwork(avalanche.FujiNetwork(), validatorParams, wallet) + if err != nil { + panic(err) + } + fmt.Printf("obtained tx id %s", txID.String()) +} diff --git a/examples/subnet.go b/examples/subnet.go index f197b3c..b6d7ecf 100644 --- a/examples/subnet.go +++ b/examples/subnet.go @@ -55,19 +55,21 @@ func DeploySubnet() { // In this example, we are using the fee-paying key generated above also as control key // and subnet auth key - // control keys are a list of keys that are permitted to make changes to a Subnet + // Control keys are a list of keys that are permitted to make changes to a Subnet // such as creating a blockchain in the Subnet and adding validators to the Subnet controlKeys := keychain.Addresses().List() - // subnet auth keys are a subset of control keys + // Subnet auth keys are a subset of control keys that will be used to sign transactions that + // modify a Subnet (such as creating a blockchain in the Subnet and adding validators to the + // Subnet) // - // they are the keys that will be used to sign transactions that modify a Subnet - // number of keys in subnetAuthKeys has to be more than or equal to threshold - // all keys in subnetAuthKeys have to sign the transaction before the transaction - // can be committed on chain + // Number of keys in subnetAuthKeys has to be equal to the threshold value provided during + // CreateSubnetTx. + // + // All keys in subnetAuthKeys have to sign the transaction before the transaction subnetAuthKeys := keychain.Addresses().List() threshold := 1 - newSubnet.SetSubnetCreateParams(controlKeys, uint32(threshold)) + newSubnet.SetSubnetControlParams(controlKeys, uint32(threshold)) wallet, _ := wallet.New( context.Background(), @@ -79,16 +81,20 @@ func DeploySubnet() { }, ) + // Build and Sign CreateSubnetTx with our fee paying key deploySubnetTx, _ := newSubnet.CreateSubnetTx(wallet) + // Commit our CreateSubnetTx on chain subnetID, _ := newSubnet.Commit(*deploySubnetTx, wallet, true) fmt.Printf("subnetID %s \n", subnetID.String()) // we need to wait to allow the transaction to reach other nodes in Fuji time.Sleep(2 * time.Second) - newSubnet.SetBlockchainCreateParams(subnetAuthKeys) + newSubnet.SetSubnetAuthKeys(subnetAuthKeys) + // Build and Sign CreateChainTx with our fee paying key (which is also our subnet auth key) deployChainTx, _ := newSubnet.CreateBlockchainTx(wallet) - // since we are using the fee paying key as control key too, we can commit the transaction + // Commit our CreateChainTx on chain + // Since we are using the fee paying key as control key too, we can commit the transaction // on chain immediately since the number of signatures has been reached blockchainID, _ := newSubnet.Commit(*deployChainTx, wallet, true) fmt.Printf("blockchainID %s \n", blockchainID.String()) @@ -139,7 +145,7 @@ func DeploySubnetWithLedger() { controlKeys := addressesIDs subnetAuthKeys := addressesIDs threshold := 1 - newSubnet.SetSubnetCreateParams(controlKeys, uint32(threshold)) + newSubnet.SetSubnetControlParams(controlKeys, uint32(threshold)) // Pay and Sign CreateSubnet Tx with fee paying key A using Ledger deploySubnetTx, _ := newSubnet.CreateSubnetTx(walletA) @@ -149,7 +155,7 @@ func DeploySubnetWithLedger() { // we need to wait to allow the transaction to reach other nodes in Fuji time.Sleep(2 * time.Second) - newSubnet.SetBlockchainCreateParams(subnetAuthKeys) + newSubnet.SetSubnetAuthKeys(subnetAuthKeys) // Pay and sign CreateChain Tx with fee paying key A using Ledger deployChainTx, _ := newSubnet.CreateBlockchainTx(walletA) diff --git a/examples/subnet_ add_validator.go b/examples/subnet_ add_validator.go new file mode 100644 index 0000000..1cfba70 --- /dev/null +++ b/examples/subnet_ add_validator.go @@ -0,0 +1,125 @@ +// Copyright (C) 2024, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package examples + +import ( + "context" + "fmt" + "github.com/ava-labs/avalanche-tooling-sdk-go/avalanche" + "github.com/ava-labs/avalanche-tooling-sdk-go/constants" + "github.com/ava-labs/avalanche-tooling-sdk-go/keychain" + "github.com/ava-labs/avalanche-tooling-sdk-go/node" + "github.com/ava-labs/avalanche-tooling-sdk-go/subnet" + "github.com/ava-labs/avalanche-tooling-sdk-go/validator" + "github.com/ava-labs/avalanche-tooling-sdk-go/wallet" + "github.com/ava-labs/avalanchego/ids" + "github.com/ava-labs/avalanchego/utils/set" + "github.com/ava-labs/avalanchego/vms/secp256k1fx" + "github.com/ava-labs/avalanchego/wallet/subnet/primary" + "time" +) + +func AddSubnetValidator() { + // We are using existing Subnet that we have already deployed on Fuji + subnetParams := subnet.SubnetParams{ + GenesisFilePath: "GENESIS_FILE_PATH", + Name: "SUBNET_NAME", + } + + newSubnet, err := subnet.New(&subnetParams) + if err != nil { + panic(err) + } + + subnetID, err := ids.FromString("SUBNET_ID") + if err != nil { + panic(err) + } + + // Genesis doesn't contain the deployed Subnet's SubnetID, we need to first set the Subnet ID + newSubnet.SetSubnetID(subnetID) + + // We are using existing host + node := node.Node{ + // NodeID is Avalanche Node ID of the node + NodeID: "NODE_ID", + // IP address of the node + IP: "NODE_IP_ADDRESS", + // SSH configuration for the node + SSHConfig: node.SSHConfig{ + User: constants.RemoteHostUser, + PrivateKeyPath: "NODE_KEYPAIR_PRIVATE_KEY_PATH", + }, + // Role is the role that we expect the host to be (Validator, API, AWMRelayer, Loadtest or + // Monitor) + Roles: []node.SupportedRole{node.Validator}, + } + + // Here we are assuming that the node is currently validating the Primary Network, which is + // a requirement before the node can start validating a Subnet. + // To have a node validate the Primary Network, call node.ValidatePrimaryNetwork + // Now we are calling the node to start tracking the Subnet + subnetIDsToValidate := []string{newSubnet.SubnetID.String()} + if err := node.SyncSubnets(subnetIDsToValidate); err != nil { + panic(err) + } + + // Node is now tracking the Subnet + + // Key that will be used for paying the transaction fees of Subnet AddValidator Tx + // + // In our example, this Key is also the control Key to the Subnet, so we are going to use + // this key to also sign the Subnet AddValidator tx + network := avalanche.FujiNetwork() + keychain, err := keychain.NewKeychain(network, "PRIVATE_KEY_FILEPATH", nil) + if err != nil { + panic(err) + } + + wallet, err := wallet.New( + context.Background(), + &primary.WalletConfig{ + URI: network.Endpoint, + AVAXKeychain: keychain.Keychain, + EthKeychain: secp256k1fx.NewKeychain(), + PChainTxsToFetch: set.Of(subnetID), + }, + ) + if err != nil { + panic(err) + } + + nodeID, err := ids.NodeIDFromString(node.NodeID) + if err != nil { + panic(err) + } + + validatorParams := validator.SubnetValidatorParams{ + NodeID: nodeID, + // Validate Subnet for 48 hours + Duration: 48 * time.Hour, + Weight: 20, + } + + // We need to set Subnet Auth Keys for this transaction since Subnet AddValidator is + // a Subnet-changing transaction + // + // In this example, the example Subnet was created with only 1 key as control key with a threshold of 1 + // and the control key is the key contained in the keychain object, so we are going to use the + // key contained in the keychain object as the Subnet Auth Key for Subnet AddValidator tx + subnetAuthKeys := keychain.Addresses().List() + newSubnet.SetSubnetAuthKeys(subnetAuthKeys) + + addValidatorTx, err := newSubnet.AddValidator(wallet, validatorParams) + if err != nil { + panic(err) + } + + // Since it has the required signatures, we will now commit the transaction on chain + txID, err := newSubnet.Commit(*addValidatorTx, wallet, true) + if err != nil { + panic(err) + } + fmt.Printf("obtained tx id %s", txID.String()) +} diff --git a/examples/subnet_ multisig.go b/examples/subnet_ multisig.go index 21151b4..1fbc0c6 100644 --- a/examples/subnet_ multisig.go +++ b/examples/subnet_ multisig.go @@ -28,9 +28,9 @@ func DeploySubnetMultiSig() { // Create three keys that will be used as control keys of the subnet // NewKeychain will generate a new key pair in the provided path if no .pk file currently // exists in the provided path - keychainA, _ := keychain.NewKeychain(network, "KEY_PATH_A") - keychainB, _ := keychain.NewKeychain(network, "KEY_PATH_B") - keychainC, _ := keychain.NewKeychain(network, "KEY_PATH_C") + keychainA, _ := keychain.NewKeychain(network, "KEY_PATH_A", nil) + keychainB, _ := keychain.NewKeychain(network, "KEY_PATH_B", nil) + keychainC, _ := keychain.NewKeychain(network, "KEY_PATH_C", nil) // In this example, we are using the fee-paying key generated above also as control key // and subnet auth key @@ -55,7 +55,7 @@ func DeploySubnetMultiSig() { // at least two signatures are required to be able to send the CreateChain transaction on-chain // note that threshold does not apply to CreateSubnet transaction threshold := 2 - newSubnet.SetSubnetCreateParams(controlKeys, uint32(threshold)) + newSubnet.SetSubnetControlParams(controlKeys, uint32(threshold)) // Key A will be used for paying the transaction fees of CreateSubnetTx and CreateChainTx walletA, _ := wallet.New( @@ -75,7 +75,7 @@ func DeploySubnetMultiSig() { // we need to wait to allow the transaction to reach other nodes in Fuji time.Sleep(2 * time.Second) - newSubnet.SetBlockchainCreateParams(subnetAuthKeys) + newSubnet.SetSubnetAuthKeys(subnetAuthKeys) deployChainTx, err := newSubnet.CreateBlockchainTx(walletA) if err != nil { fmt.Errorf("error signing tx walletA: %w", err) diff --git a/key/soft_key.go b/key/soft_key.go index 8521a70..b47c5cc 100644 --- a/key/soft_key.go +++ b/key/soft_key.go @@ -12,6 +12,7 @@ import ( "os" "strings" + "github.com/ava-labs/avalanche-tooling-sdk-go/constants" "github.com/ava-labs/avalanche-tooling-sdk-go/utils" "github.com/ava-labs/avalanchego/ids" @@ -285,7 +286,7 @@ func (m *SoftKey) PrivKeyHex() string { // Saves the private key to disk with hex encoding. func (m *SoftKey) Save(p string) error { - return os.WriteFile(p, []byte(m.PrivKeyHex()), utils.WriteReadUserOnlyPerms) + return os.WriteFile(p, []byte(m.PrivKeyHex()), constants.WriteReadUserOnlyPerms) } func (m *SoftKey) P(networkHRP string) (string, error) { diff --git a/node/add_validator_primary.go b/node/add_validator_primary.go new file mode 100644 index 0000000..bb4609a --- /dev/null +++ b/node/add_validator_primary.go @@ -0,0 +1,132 @@ +// Copyright (C) 2024, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package node + +import ( + "fmt" + "time" + + "github.com/ava-labs/avalanche-tooling-sdk-go/validator" + + remoteconfig "github.com/ava-labs/avalanche-tooling-sdk-go/node/config" + + "github.com/ava-labs/avalanche-tooling-sdk-go/constants" + "github.com/ava-labs/avalanche-tooling-sdk-go/subnet" + + "github.com/ava-labs/avalanche-tooling-sdk-go/avalanche" + "github.com/ava-labs/avalanche-tooling-sdk-go/utils" + "github.com/ava-labs/avalanchego/utils/crypto/bls" + "github.com/ava-labs/avalanchego/vms/platformvm/signer" + "github.com/ava-labs/avalanchego/vms/secp256k1fx" + "github.com/ava-labs/avalanchego/wallet/subnet/primary/common" + + "github.com/ava-labs/avalanche-tooling-sdk-go/wallet" + "github.com/ava-labs/avalanchego/ids" + "github.com/ava-labs/avalanchego/vms/platformvm/txs" + "golang.org/x/net/context" +) + +// ValidatePrimaryNetwork adds node as primary network validator. +// It adds the node in the specified network (Fuji / Mainnet / Devnet) +// and uses the wallet provided in the argument to pay for the transaction fee +func (h *Node) ValidatePrimaryNetwork( + network avalanche.Network, + validatorParams validator.PrimaryNetworkValidatorParams, + wallet wallet.Wallet, +) (ids.ID, error) { + if validatorParams.NodeID == ids.EmptyNodeID { + return ids.Empty, subnet.ErrEmptyValidatorNodeID + } + + if validatorParams.Duration == 0 { + return ids.Empty, subnet.ErrEmptyValidatorDuration + } + + minValStake, err := network.GetMinStakingAmount() + if err != nil { + return ids.Empty, err + } + + if validatorParams.StakeAmount < minValStake { + return ids.Empty, fmt.Errorf("invalid weight, must be greater than or equal to %d: %d", minValStake, validatorParams.StakeAmount) + } + + if validatorParams.DelegationFee == 0 { + validatorParams.DelegationFee = network.GenesisParams().MinDelegationFee + } + + if err = h.GetBLSKeyFromRemoteHost(); err != nil { + return ids.Empty, fmt.Errorf("unable to set BLS key of node from remote host due to %w", err) + } + + wallet.SetSubnetAuthMultisig([]ids.ShortID{}) + + owner := &secp256k1fx.OutputOwners{ + Threshold: 1, + Addrs: []ids.ShortID{ + wallet.Addresses()[0], + }, + } + + proofOfPossession := signer.NewProofOfPossession(h.BlsSecretKey) + nodeID, err := ids.NodeIDFromString(h.NodeID) + if err != nil { + return ids.Empty, err + } + + unsignedTx, err := wallet.P().Builder().NewAddPermissionlessValidatorTx( + &txs.SubnetValidator{ + Validator: txs.Validator{ + NodeID: nodeID, + End: uint64(time.Now().Add(validatorParams.Duration).Unix()), + Wght: validatorParams.StakeAmount, + }, + Subnet: ids.Empty, + }, + proofOfPossession, + wallet.P().Builder().Context().AVAXAssetID, + owner, + owner, + validatorParams.DelegationFee, + ) + if err != nil { + return ids.Empty, fmt.Errorf("error building tx: %w", err) + } + + tx := txs.Tx{Unsigned: unsignedTx} + if err := wallet.P().Signer().Sign(context.Background(), &tx); err != nil { + return ids.Empty, fmt.Errorf("error signing tx: %w", err) + } + + ctx, cancel := utils.GetAPIContext() + defer cancel() + err = wallet.P().IssueTx( + &tx, + common.WithContext(ctx), + ) + if err != nil { + if ctx.Err() != nil { + err = fmt.Errorf("timeout issuing/verifying tx with ID %s: %w", tx.ID(), err) + } else { + err = fmt.Errorf("error issuing tx with ID %s: %w", tx.ID(), err) + } + return ids.Empty, err + } + + return tx.ID(), nil +} + +// GetBLSKeyFromRemoteHost gets BLS information from remote host and sets the BlsSecretKey value in Node object +func (h *Node) GetBLSKeyFromRemoteHost() error { + blsKeyBytes, err := h.ReadFileBytes(remoteconfig.GetRemoteBLSKeyFile(), constants.SSHFileOpsTimeout) + if err != nil { + return err + } + blsSk, err := bls.SecretKeyFromBytes(blsKeyBytes) + if err != nil { + return err + } + h.BlsSecretKey = blsSk + return nil +} diff --git a/node/add_validator_primary_test.go b/node/add_validator_primary_test.go new file mode 100644 index 0000000..45b01ed --- /dev/null +++ b/node/add_validator_primary_test.go @@ -0,0 +1,73 @@ +// Copyright (C) 2024, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package node + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/ava-labs/avalanche-tooling-sdk-go/validator" + + "github.com/ava-labs/avalanche-tooling-sdk-go/avalanche" + "github.com/ava-labs/avalanche-tooling-sdk-go/constants" + "github.com/ava-labs/avalanche-tooling-sdk-go/keychain" + "github.com/ava-labs/avalanche-tooling-sdk-go/wallet" + "github.com/ava-labs/avalanchego/ids" + "github.com/ava-labs/avalanchego/utils/units" + "github.com/ava-labs/avalanchego/vms/secp256k1fx" + "github.com/ava-labs/avalanchego/wallet/subnet/primary" +) + +func TestNodesValidatePrimaryNetwork(t *testing.T) { + require := require.New(t) + // We are using an existing host + node := Node{ + // NodeID is Avalanche Node ID of the node + NodeID: "NODE_ID", + // IP address of the node + IP: "NODE_IP_ADDRESS", + // SSH configuration for the node + SSHConfig: SSHConfig{ + User: constants.RemoteHostUser, + PrivateKeyPath: "NODE_KEYPAIR_PRIVATE_KEY_PATH", + }, + // Role of the node can be Validator, API, AWMRelayer, Loadtest, or Monitor + Roles: []SupportedRole{Validator}, + } + + nodeID, err := ids.NodeIDFromString(node.NodeID) + require.NoError(err) + + validatorParams := validator.PrimaryNetworkValidatorParams{ + NodeID: nodeID, + // Validate Primary Network for 48 hours + Duration: 48 * time.Hour, + // Stake 2 AVAX + StakeAmount: 2 * units.Avax, + } + + network := avalanche.FujiNetwork() + keychain, err := keychain.NewKeychain(network, "PRIVATE_KEY_FILEPATH", nil) + require.NoError(err) + + wallet, err := wallet.New( + context.Background(), + &primary.WalletConfig{ + URI: network.Endpoint, + AVAXKeychain: keychain.Keychain, + EthKeychain: secp256k1fx.NewKeychain(), + PChainTxsToFetch: nil, + }, + ) + require.NoError(err) + + txID, err := node.ValidatePrimaryNetwork(avalanche.FujiNetwork(), validatorParams, wallet) + require.NoError(err) + + fmt.Printf("obtained tx id %s", txID.String()) +} diff --git a/node/config/avalanche.go b/node/config/avalanche.go index d3a6482..73516d7 100644 --- a/node/config/avalanche.go +++ b/node/config/avalanche.go @@ -75,6 +75,10 @@ func RenderAvalancheCChainConfig(config AvalancheConfigInputs) ([]byte, error) { } } +func GetRemoteBLSKeyFile() string { + return filepath.Join(constants.CloudNodeStakingPath, constants.BLSKeyFileName) +} + func GetRemoteAvalancheNodeConfig() string { return filepath.Join(constants.CloudNodeConfigPath, "node.json") } diff --git a/node/create.go b/node/create.go index e02b13c..218e377 100644 --- a/node/create.go +++ b/node/create.go @@ -187,7 +187,7 @@ func createCloudInstances(ctx context.Context, cp CloudParams, count int, useSta Cloud: cp.Cloud(), CloudConfig: cp, SSHConfig: SSHConfig{ - User: constants.AnsibleSSHUser, + User: constants.RemoteHostUser, PrivateKeyPath: sshPrivateKeyPath, }, Roles: nil, @@ -233,7 +233,7 @@ func createCloudInstances(ctx context.Context, cp CloudParams, count int, useSta Cloud: cp.Cloud(), CloudConfig: cp, SSHConfig: SSHConfig{ - User: constants.AnsibleSSHUser, + User: constants.RemoteHostUser, PrivateKeyPath: sshPrivateKeyPath, }, Roles: nil, diff --git a/node/create_test.go b/node/create_test.go index 7fc9eaf..9030f14 100644 --- a/node/create_test.go +++ b/node/create_test.go @@ -9,34 +9,34 @@ import ( "testing" "time" + "github.com/stretchr/testify/require" + awsAPI "github.com/ava-labs/avalanche-tooling-sdk-go/cloud/aws" "github.com/ava-labs/avalanche-tooling-sdk-go/avalanche" "github.com/ava-labs/avalanche-tooling-sdk-go/utils" ) -func TestCreateNodes(_ *testing.T) { +func TestCreateNodes(t *testing.T) { + require := require.New(t) ctx := context.Background() // Get the default cloud parameters for AWS cp, err := GetDefaultCloudParams(ctx, AWSCloud) - if err != nil { - panic(err) - } + require.NoError(err) securityGroupName := "SECURITY_GROUP_NAME" sgID, err := awsAPI.CreateSecurityGroup(ctx, securityGroupName, cp.AWSConfig.AWSProfile, cp.Region) - if err != nil { - panic(err) - } + require.NoError(err) + // Set the security group we are using when creating our Avalanche Nodes cp.AWSConfig.AWSSecurityGroupID = sgID cp.AWSConfig.AWSSecurityGroupName = securityGroupName keyPairName := "KEY_PAIR_NAME" sshPrivateKeyPath := utils.ExpandHome("PRIVATE_KEY_FILEPATH") - if err := awsAPI.CreateSSHKeyPair(ctx, cp.AWSConfig.AWSProfile, cp.Region, keyPairName, sshPrivateKeyPath); err != nil { - panic(err) - } + err = awsAPI.CreateSSHKeyPair(ctx, cp.AWSConfig.AWSProfile, cp.Region, keyPairName, sshPrivateKeyPath) + require.NoError(err) + // Set the key pair we are using when creating our Avalanche Nodes cp.AWSConfig.AWSKeyPair = keyPairName @@ -64,9 +64,7 @@ func TestCreateNodes(_ *testing.T) { UseStaticIP: false, SSHPrivateKeyPath: sshPrivateKeyPath, }) - if err != nil { - panic(err) - } + require.NoError(err) fmt.Println("Successfully created Avalanche Validators") @@ -80,12 +78,12 @@ func TestCreateNodes(_ *testing.T) { // Wait for the host to be ready (only needs to be done once for newly created nodes) fmt.Println("Waiting for SSH shell") if err := h.WaitForSSHShell(sshTimeout); err != nil { - panic(err) + require.NoError(err) } fmt.Println("SSH shell ready to execute commands") // Run a command on the host if output, err := h.Commandf(nil, sshCommandTimeout, "echo 'Hello, %s!'", "World"); err != nil { - panic(err) + require.NoError(err) } else { fmt.Println(string(output)) } @@ -93,7 +91,7 @@ func TestCreateNodes(_ *testing.T) { time.Sleep(10 * time.Second) // check if avalanchego is running if output, err := h.Commandf(nil, sshCommandTimeout, "docker ps"); err != nil { - panic(err) + require.NoError(err) } else { fmt.Println(string(output)) } @@ -112,30 +110,23 @@ func TestCreateNodes(_ *testing.T) { UseStaticIP: false, SSHPrivateKeyPath: sshPrivateKeyPath, }) - if err != nil { - panic(err) - } + require.NoError(err) fmt.Println("Successfully created monitoring node") fmt.Println("Linking monitoring node with Avalanche Validator nodes ...") // Link the 2 validator nodes previously created with the monitoring host so that // the monitoring host can start tracking the validator nodes metrics and collecting their logs - if err := monitoringHosts[0].MonitorNodes(ctx, hosts, ""); err != nil { - panic(err) - } + err = monitoringHosts[0].MonitorNodes(ctx, hosts, "") + require.NoError(err) fmt.Println("Successfully linked monitoring node with Avalanche Validator nodes") fmt.Println("Terminating all created nodes ...") // Destroy all created nodes for _, h := range hosts { err = h.Destroy(ctx) - if err != nil { - panic(err) - } + require.NoError(err) } err = monitoringHosts[0].Destroy(ctx) - if err != nil { - panic(err) - } + require.NoError(err) fmt.Println("All nodes terminated") } diff --git a/node/node.go b/node/node.go index ab67085..b851a76 100644 --- a/node/node.go +++ b/node/node.go @@ -17,6 +17,8 @@ import ( "sync" "time" + "github.com/ava-labs/avalanchego/utils/crypto/bls" + "github.com/melbahja/goph" "golang.org/x/crypto/ssh" @@ -75,6 +77,10 @@ type Node struct { // Logger for node Logger avalanche.LeveledLogger + + // BLS provides a way to aggregate signatures off chain into a single signature that can be efficiently verified on chain. + // For more information about how BLS is used on the P-Chain, please head to https://docs.avax.network/cross-chain/avalanche-warp-messaging/deep-dive#bls-multi-signatures-with-public-key-aggregation + BlsSecretKey *bls.SecretKey } // NewNodeConnection creates a new SSH connection to the node @@ -171,11 +177,11 @@ func (h *Node) Upload(localFile string, remoteFile string, timeout time.Duration return err } } - _, err := utils.TimedFunction( + _, err := utils.CallWithTimeout( + "upload", func() (interface{}, error) { return nil, h.connection.Upload(localFile, remoteFile) }, - "upload", timeout, ) if err != nil { @@ -210,11 +216,11 @@ func (h *Node) Download(remoteFile string, localFile string, timeout time.Durati if err := os.MkdirAll(filepath.Dir(localFile), os.ModePerm); err != nil { return err } - _, err := utils.TimedFunction( + _, err := utils.CallWithTimeout( + "download", func() (interface{}, error) { return nil, h.connection.Download(remoteFile, localFile) }, - "download", timeout, ) if err != nil { @@ -256,11 +262,11 @@ func (h *Node) MkdirAll(remoteDir string, timeout time.Duration) error { return err } } - _, err := utils.TimedFunction( + _, err := utils.CallWithTimeout( + "mkdir", func() (interface{}, error) { return nil, h.UntimedMkdirAll(remoteDir) }, - "mkdir", timeout, ) if err != nil { @@ -327,23 +333,16 @@ func (h *Node) Forward(httpRequest string, timeout time.Duration) ([]byte, error return nil, err } } - retI, err := utils.TimedFunctionWithRetry( - func() (interface{}, error) { - return h.UntimedForward(httpRequest) - }, - "post over ssh", + return utils.Retry( + utils.WrapContext( + func() ([]byte, error) { + return h.UntimedForward(httpRequest) + }, + ), timeout, 3, - 2*time.Second, + fmt.Sprintf("failure on node %s post over ssh", h.IP), ) - if err != nil { - err = fmt.Errorf("%w for node %s", err, h.IP) - } - ret := []byte(nil) - if retI != nil { - ret = retI.([]byte) - } - return ret, err } // UntimedForward forwards the TCP connection to a remote address. diff --git a/node/staking.go b/node/staking.go index 2f60992..2cf085f 100644 --- a/node/staking.go +++ b/node/staking.go @@ -13,8 +13,15 @@ import ( "github.com/ava-labs/avalanchego/staking" ) -func (h *Node) ProvideStakingCertAndKey(keyPath string) error { - if nodeID, err := GenerateNodeCertAndKeys(keyPath); err != nil { +// ProvideStakingFiles generates the files needed to validate the primary network: +// - staker.crt, staker.key, more information can be found at https://docs.avax.network/nodes/validate/how-to-stake#secret-management +// - The file containing the node's BLS information: signer.key (more information can be found at https://docs.avax.network/cross-chain/avalanche-warp-messaging/deep-dive#bls-multi-signatures-with-public-key-aggregation) +// +// and stores them in the provided directory in argument in local machine +// and subsequently uploads these files into the remote host in /home/ubuntu/.avalanchego/staking/ +// directory +func (h *Node) ProvideStakingFiles(keyPath string) error { + if nodeID, err := GenerateStakingFiles(keyPath); err != nil { return err } else { h.Logger.Infof("Generated Staking Cert and Key for NodeID: %s in folder %s", nodeID.String(), keyPath) @@ -22,8 +29,9 @@ func (h *Node) ProvideStakingCertAndKey(keyPath string) error { return h.RunSSHUploadStakingFiles(keyPath) } -// GenerateNodeCertAndKeys generates a node certificate and keys and return nodeID -func GenerateNodeCertAndKeys(keyPath string) (ids.NodeID, error) { +// GenerateStakingFiles generates the following files: staker.crt, staker.key and signer.key +// and stores them in the provided directory in argument in local machine +func GenerateStakingFiles(keyPath string) (ids.NodeID, error) { if err := os.MkdirAll(keyPath, constants.DefaultPerms755); err != nil { return ids.EmptyNodeID, err } diff --git a/node/templates/avalanchego.docker-compose.yml b/node/templates/avalanchego.docker-compose.yml index a6ac106..d64a4b7 100644 --- a/node/templates/avalanchego.docker-compose.yml +++ b/node/templates/avalanchego.docker-compose.yml @@ -27,10 +27,10 @@ services: ports: - "9650:9650" - "9651:9651" - networks: - - avalanchego_net + network_mode: "host" {{ end }} {{ end }} + {{if .WithMonitoring}} promtail: image: grafana/promtail:3.0.0 @@ -48,8 +48,6 @@ services: volumes: - /home/ubuntu/.avalanchego/logs:/logs:ro - /home/ubuntu/.avalanche-cli/services/promtail:/etc/promtail:ro - networks: - - avalanchego_net {{ end }} node-exporter: image: prom/node-exporter:v1.7.0 @@ -68,11 +66,8 @@ services: {{if .E2E }} networks: - avalanchego_net_{{.E2ESuffix}} -{{ else }} - networks: - - avalanchego_net -{{ end }} {{ end }} +{{end}} {{if .E2E }} volumes: @@ -80,8 +75,5 @@ volumes: avalanchego_logs_{{.E2ESuffix}}: networks: avalanchego_net_{{.E2ESuffix}}: -{{ else }} -networks: - avalanchego_net: {{ end }} \ No newline at end of file diff --git a/node/templates/awmrelayer.docker-compose.yml b/node/templates/awmrelayer.docker-compose.yml index bbf48c0..3861c4a 100644 --- a/node/templates/awmrelayer.docker-compose.yml +++ b/node/templates/awmrelayer.docker-compose.yml @@ -5,10 +5,7 @@ services: container_name: awm-relayer restart: unless-stopped user: "1000:1000" # ubuntu user - networks: - - avalanchego_net + network_mode: "host" volumes: - /home/ubuntu/.avalanche-cli/services/awm-relayer:/.awm-relayer:rw command: 'awm-relayer --config-file /.awm-relayer/awm-relayer-config.json' -networks: - avalanchego_net: diff --git a/node/templates/monitoring.docker-compose.yml b/node/templates/monitoring.docker-compose.yml index 6cd25de..0c101cf 100644 --- a/node/templates/monitoring.docker-compose.yml +++ b/node/templates/monitoring.docker-compose.yml @@ -15,8 +15,6 @@ services: - '--storage.tsdb.path=/var/lib/prometheus' links: - node-exporter - networks: - - monitoring_net grafana: image: grafana/grafana:10.4.1 @@ -31,8 +29,6 @@ services: links: - prometheus - loki - networks: - - monitoring_net loki: image: grafana/loki:3.0.0 @@ -45,8 +41,6 @@ services: volumes: - /home/ubuntu/.avalanche-cli/services/loki:/etc/loki:ro - /home/ubuntu/.avalanche-cli/services/loki/data:/var/lib/loki:rw - networks: - - monitoring_net node-exporter: image: prom/node-exporter:v1.7.0 @@ -54,12 +48,7 @@ services: restart: unless-stopped ports: - "9100:9100" - networks: - - monitoring_net volumes: - /proc:/host/proc:ro - /sys:/host/sys:ro - /:/rootfs:ro - -networks: - monitoring_net: diff --git a/subnet/add_validator_subnet.go b/subnet/add_validator_subnet.go index f18838e..964acd9 100644 --- a/subnet/add_validator_subnet.go +++ b/subnet/add_validator_subnet.go @@ -8,33 +8,27 @@ import ( "fmt" "time" + "github.com/ava-labs/avalanche-tooling-sdk-go/validator" + + "golang.org/x/net/context" + "github.com/ava-labs/avalanche-tooling-sdk-go/multisig" "github.com/ava-labs/avalanche-tooling-sdk-go/wallet" "github.com/ava-labs/avalanchego/ids" "github.com/ava-labs/avalanchego/vms/platformvm/txs" - "golang.org/x/net/context" ) -type ValidatorParams struct { - NodeID ids.NodeID - - Duration time.Duration - - Weight uint64 -} - var ( ErrEmptyValidatorNodeID = errors.New("validator node id is not provided") ErrEmptyValidatorDuration = errors.New("validator duration is not provided") - ErrEmptyValidatorWeight = errors.New("validator weight is not provided") ErrEmptySubnetID = errors.New("subnet ID is not provided") + ErrEmptySubnetAuth = errors.New("no subnet auth keys is provided") ) // AddValidator adds validator to subnet // Before an Avalanche Node can be added as a validator to a Subnet, the node must already be -// tracking the subnet -// TODO: add more description once node join subnet sdk is done -func (c *Subnet) AddValidator(wallet wallet.Wallet, validatorInput ValidatorParams) (*multisig.Multisig, error) { +// tracking the subnet, which can be done by calling SyncSubnets in node package +func (c *Subnet) AddValidator(wallet wallet.Wallet, validatorInput validator.SubnetValidatorParams) (*multisig.Multisig, error) { if validatorInput.NodeID == ids.EmptyNodeID { return nil, ErrEmptyValidatorNodeID } @@ -42,11 +36,14 @@ func (c *Subnet) AddValidator(wallet wallet.Wallet, validatorInput ValidatorPara return nil, ErrEmptyValidatorDuration } if validatorInput.Weight == 0 { - return nil, ErrEmptyValidatorWeight + validatorInput.Weight = 20 } if c.SubnetID == ids.Empty { return nil, ErrEmptySubnetID } + if len(c.DeployInfo.SubnetAuthKeys) == 0 { + return nil, ErrEmptySubnetAuth + } wallet.SetSubnetAuthMultisig(c.DeployInfo.SubnetAuthKeys) diff --git a/subnet/add_validator_subnet_test.go b/subnet/add_validator_subnet_test.go new file mode 100644 index 0000000..d885c8c --- /dev/null +++ b/subnet/add_validator_subnet_test.go @@ -0,0 +1,83 @@ +// Copyright (C) 2024, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package subnet + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/ava-labs/avalanche-tooling-sdk-go/avalanche" + "github.com/ava-labs/avalanche-tooling-sdk-go/keychain" + "github.com/ava-labs/avalanche-tooling-sdk-go/validator" + "github.com/ava-labs/avalanche-tooling-sdk-go/wallet" + "github.com/ava-labs/avalanchego/ids" + "github.com/ava-labs/avalanchego/utils/set" + "github.com/ava-labs/avalanchego/vms/secp256k1fx" + "github.com/ava-labs/avalanchego/wallet/subnet/primary" +) + +func TestValidateSubnet(t *testing.T) { + require := require.New(t) + subnetParams := SubnetParams{ + GenesisFilePath: "GENESIS_FILE_PATH", + Name: "SUBNET_NAME", + } + + newSubnet, err := New(&subnetParams) + require.NoError(err) + + // Genesis doesn't contain the deployed Subnet's SubnetID, we need to first set the Subnet ID + subnetID, err := ids.FromString("SUBNET_ID") + require.NoError(err) + + newSubnet.SetSubnetID(subnetID) + + network := avalanche.FujiNetwork() + keychain, err := keychain.NewKeychain(network, "PRIVATE_KEY_FILEPATH", nil) + require.NoError(err) + + wallet, err := wallet.New( + context.Background(), + &primary.WalletConfig{ + URI: network.Endpoint, + AVAXKeychain: keychain.Keychain, + EthKeychain: secp256k1fx.NewKeychain(), + PChainTxsToFetch: set.Of(subnetID), + }, + ) + require.NoError(err) + + nodeID, err := ids.NodeIDFromString("VALIDATOR_NODEID") + require.NoError(err) + + validatorParams := validator.SubnetValidatorParams{ + NodeID: nodeID, + // Validate Subnet for 48 hours + Duration: 48 * time.Hour, + // Setting weight of subnet validator to 20 (default value) + Weight: 20, + } + + // We need to set Subnet Auth Keys for this transaction since Subnet AddValidator is + // a Subnet-changing transaction + // + // In this example, the example Subnet was created with only 1 key as control key with a threshold of 1 + // and the control key is the key contained in the keychain object, so we are going to use the + // key contained in the keychain object as the Subnet Auth Key for Subnet AddValidator tx + subnetAuthKeys := keychain.Addresses().List() + newSubnet.SetSubnetAuthKeys(subnetAuthKeys) + + addValidatorTx, err := newSubnet.AddValidator(wallet, validatorParams) + require.NoError(err) + + // Since it has the required signatures, we will now commit the transaction on chain + txID, err := newSubnet.Commit(*addValidatorTx, wallet, true) + require.NoError(err) + + fmt.Printf("obtained tx id %s", txID.String()) +} diff --git a/subnet/subnet.go b/subnet/subnet.go index 2a6fbc3..aa359e6 100644 --- a/subnet/subnet.go +++ b/subnet/subnet.go @@ -114,12 +114,18 @@ func (c *Subnet) SetParams(controlKeys []ids.ShortID, subnetAuthKeys []ids.Short } } -func (c *Subnet) SetSubnetCreateParams(controlKeys []ids.ShortID, threshold uint32) { +// SetSubnetControlParams sets: +// - control keys, which are keys that are allowed to make changes to a Subnet +// - threshold, which is the number of keys that need to sign a transaction that changes +// a Subnet +func (c *Subnet) SetSubnetControlParams(controlKeys []ids.ShortID, threshold uint32) { c.DeployInfo.ControlKeys = controlKeys c.DeployInfo.Threshold = threshold } -func (c *Subnet) SetBlockchainCreateParams(subnetAuthKeys []ids.ShortID) { +// SetSubnetAuthKeys sets subnetAuthKeys, which are keys that are being used to sign a transaction +// that changes a Subnet +func (c *Subnet) SetSubnetAuthKeys(subnetAuthKeys []ids.ShortID) { c.DeployInfo.SubnetAuthKeys = subnetAuthKeys } @@ -183,6 +189,10 @@ func New(subnetParams *SubnetParams) (*Subnet, error) { return &subnet, nil } +func (c *Subnet) SetSubnetID(subnetID ids.ID) { + c.SubnetID = subnetID +} + func createEvmGenesis( subnetEVMParams *SubnetEVMParams, ) ([]byte, error) { diff --git a/subnet/subnet_test.go b/subnet/subnet_test.go index e4d4897..ca1359b 100644 --- a/subnet/subnet_test.go +++ b/subnet/subnet_test.go @@ -57,7 +57,7 @@ func TestSubnetDeploy(t *testing.T) { controlKeys := keychain.Addresses().List() subnetAuthKeys := keychain.Addresses().List() threshold := 1 - newSubnet.SetSubnetCreateParams(controlKeys, uint32(threshold)) + newSubnet.SetSubnetControlParams(controlKeys, uint32(threshold)) wallet, err := wallet.New( context.Background(), &primary.WalletConfig{ @@ -74,7 +74,7 @@ func TestSubnetDeploy(t *testing.T) { require.NoError(err) fmt.Printf("subnetID %s \n", subnetID.String()) time.Sleep(2 * time.Second) - newSubnet.SetBlockchainCreateParams(subnetAuthKeys) + newSubnet.SetSubnetAuthKeys(subnetAuthKeys) deployChainTx, err := newSubnet.CreateBlockchainTx(wallet) require.NoError(err) blockchainID, err := newSubnet.Commit(*deployChainTx, wallet, true) @@ -104,7 +104,7 @@ func TestSubnetDeployMultiSig(t *testing.T) { subnetAuthKeys = append(subnetAuthKeys, keychainA.Addresses().List()[0]) subnetAuthKeys = append(subnetAuthKeys, keychainB.Addresses().List()[0]) threshold := 2 - newSubnet.SetSubnetCreateParams(controlKeys, uint32(threshold)) + newSubnet.SetSubnetControlParams(controlKeys, uint32(threshold)) walletA, err := wallet.New( context.Background(), @@ -126,7 +126,7 @@ func TestSubnetDeployMultiSig(t *testing.T) { // we need to wait to allow the transaction to reach other nodes in Fuji time.Sleep(2 * time.Second) - newSubnet.SetBlockchainCreateParams(subnetAuthKeys) + newSubnet.SetSubnetAuthKeys(subnetAuthKeys) // first signature of CreateChainTx using keychain A deployChainTx, err := newSubnet.CreateBlockchainTx(walletA) require.NoError(err) @@ -173,7 +173,7 @@ func TestSubnetDeployLedger(t *testing.T) { subnetAuthKeys := addressesIDs threshold := 1 - newSubnet.SetSubnetCreateParams(controlKeys, uint32(threshold)) + newSubnet.SetSubnetControlParams(controlKeys, uint32(threshold)) walletA, err := wallet.New( context.Background(), @@ -194,7 +194,7 @@ func TestSubnetDeployLedger(t *testing.T) { time.Sleep(2 * time.Second) - newSubnet.SetBlockchainCreateParams(subnetAuthKeys) + newSubnet.SetSubnetAuthKeys(subnetAuthKeys) deployChainTx, err := newSubnet.CreateBlockchainTx(walletA) require.NoError(err) diff --git a/utils/common.go b/utils/common.go index 108c24e..6c30752 100644 --- a/utils/common.go +++ b/utils/common.go @@ -4,6 +4,7 @@ package utils import ( "context" + "errors" "fmt" "math/rand" "time" @@ -84,66 +85,77 @@ func AppendSlices[T any](slices ...[]T) []T { // Retry retries the given function until it succeeds or the maximum number of attempts is reached. func Retry[T any]( - fn func() (T, error), + fn func(context.Context) (T, error), + attempTimeout time.Duration, maxAttempts int, - retryInterval time.Duration, + errMsg string, ) (T, error) { - const defaultRetryInterval = 2 * time.Second - if retryInterval == 0 { - retryInterval = defaultRetryInterval + const defaultAttempTimeout = 2 * time.Second + if attempTimeout == 0 { + attempTimeout = defaultAttempTimeout } var ( result T err error ) for attempt := 0; attempt < maxAttempts; attempt++ { - result, err = fn() + start := time.Now() + ctx, cancel := context.WithTimeout(context.Background(), attempTimeout) + defer cancel() + result, err = fn(ctx) if err == nil { return result, nil } - time.Sleep(retryInterval) + elapsed := time.Since(start) + if elapsed < attempTimeout { + time.Sleep(attempTimeout - elapsed) + } } - return result, err + return result, fmt.Errorf( + "%s: maximum retry attempts %d reached: last err = %w", + errMsg, + maxAttempts, + err, + ) } -// TimedFunction is a function that executes the given function `f` within a specified timeout duration. -func TimedFunction( - f func() (interface{}, error), +// WrapContext adds a context based timeout to a given function +func WrapContext[T any]( + f func() (T, error), +) func(context.Context) (T, error) { + return func(ctx context.Context) (T, error) { + var ( + ret T + err error + ) + ch := make(chan struct{}) + go func() { + ret, err = f() + close(ch) + }() + select { + case <-ctx.Done(): + return ret, ctx.Err() + case <-ch: + } + return ret, err + } +} + +func CallWithTimeout[T any]( name string, + f func() (T, error), timeout time.Duration, -) (interface{}, error) { - var ( - ret interface{} - err error - ) - ch := make(chan struct{}) +) (T, error) { ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() - go func() { - ret, err = f() - close(ch) - }() - select { - case <-ctx.Done(): - return nil, fmt.Errorf("%s timeout of %d seconds", name, uint(timeout.Seconds())) - case <-ch: + ret, err := WrapContext(f)(ctx) + if errors.Is(err, context.DeadlineExceeded) { + err = fmt.Errorf("%s timeout of %d seconds", name, uint(timeout.Seconds())) } return ret, err } -// TimedFunctionWithRetry is a function that executes the given function `f` within a specified timeout duration. -func TimedFunctionWithRetry( - f func() (interface{}, error), - name string, - timeout time.Duration, - maxAttempts int, - retryInterval time.Duration, -) (interface{}, error) { - return Retry(func() (interface{}, error) { - return TimedFunction(f, name, timeout) - }, maxAttempts, retryInterval) -} - // RandomString generates a random string of the specified length. func RandomString(length int) string { randG := rand.New(rand.NewSource(time.Now().UnixNano())) // #nosec G404 diff --git a/utils/common_test.go b/utils/common_test.go index 06d6c65..eae406a 100644 --- a/utils/common_test.go +++ b/utils/common_test.go @@ -57,7 +57,7 @@ func mockFunction() (interface{}, error) { func TestRetry(t *testing.T) { success := "success" // Test with a function that always returns an error. - result, err := Retry(mockFunction, 3, 100*time.Millisecond) + result, err := Retry(WrapContext(mockFunction), 100*time.Millisecond, 3, "") if err == nil { t.Errorf("Expected an error, got nil") } @@ -69,7 +69,7 @@ func TestRetry(t *testing.T) { fn := func() (interface{}, error) { return success, nil } - result, err = Retry(fn, 3, 100*time.Millisecond) + result, err = Retry(WrapContext(fn), 100*time.Millisecond, 3, "") if err != nil { t.Errorf("Expected no error, got %v", err) } @@ -86,7 +86,7 @@ func TestRetry(t *testing.T) { } return success, nil } - result, err = Retry(fn, 5, 100*time.Millisecond) + result, err = Retry(WrapContext(fn), 100*time.Millisecond, 5, "") if err != nil { t.Errorf("Expected no error, got %v", err) } @@ -95,7 +95,7 @@ func TestRetry(t *testing.T) { } // Test with invalid retry interval. - result, err = Retry(mockFunction, 3, 0) + result, err = Retry(WrapContext(mockFunction), 0, 3, "") if err == nil { t.Errorf("Expected an error, got nil") } diff --git a/utils/ssh.go b/utils/ssh.go index fd9ca41..35799c7 100644 --- a/utils/ssh.go +++ b/utils/ssh.go @@ -20,7 +20,7 @@ func GetSCPTargetPath(ip, path string) string { if ip == "" { return path } - return fmt.Sprintf("%s@%s:%s", constants.AnsibleSSHUser, ip, path) + return fmt.Sprintf("%s@%s:%s", constants.RemoteHostUser, ip, path) } // SplitSCPPath splits the given path into node and path. diff --git a/utils/utils.go b/utils/utils.go index 40c0ad2..aecac89 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -4,19 +4,15 @@ package utils import ( "context" + "fmt" "sort" - "time" + "strings" + "github.com/ava-labs/avalanche-tooling-sdk-go/constants" "github.com/ava-labs/avalanchego/ids" "github.com/ava-labs/avalanchego/utils/formatting/address" ) -const ( - APIRequestTimeout = 30 * time.Second - APIRequestLargeTimeout = 2 * time.Minute - WriteReadUserOnlyPerms = 0o600 -) - // Unique returns a new slice containing only the unique elements from the input slice. func Unique[T comparable](arr []T) []T { visited := map[T]bool{} @@ -36,12 +32,12 @@ func Uint32Sort(arr []uint32) { // Context for API requests func GetAPIContext() (context.Context, context.CancelFunc) { - return context.WithTimeout(context.Background(), APIRequestTimeout) + return context.WithTimeout(context.Background(), constants.APIRequestTimeout) } // Context for API requests with large timeout func GetAPILargeContext() (context.Context, context.CancelFunc) { - return context.WithTimeout(context.Background(), APIRequestLargeTimeout) + return context.WithTimeout(context.Background(), constants.APIRequestLargeTimeout) } func P( @@ -55,3 +51,15 @@ func P( }, ) } + +func RemoveSurrounding(s string, left string, right string) (string, error) { + s = strings.TrimSpace(s) + if len(s) > 0 { + if len(s) < len(left)+len(right) || !strings.HasPrefix(s, left) || !strings.HasSuffix(s, right) { + return "", fmt.Errorf("expected esp %q to be of form '%s...%s'", s, left, right) + } + s = strings.TrimPrefix(s, left) + s = strings.TrimSuffix(s, right) + } + return s, nil +} diff --git a/validator/validator.go b/validator/validator.go new file mode 100644 index 0000000..f60c208 --- /dev/null +++ b/validator/validator.go @@ -0,0 +1,42 @@ +// Copyright (C) 2024, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package validator + +import ( + "time" + + "github.com/ava-labs/avalanchego/ids" +) + +type PrimaryNetworkValidatorParams struct { + // NodeID is the unique identifier of the node to be added as a validator on the Primary Network. + NodeID ids.NodeID + + // Duration is how long the node will be staking the Primary Network + // Duration has to be greater than or equal to minimum duration for the specified network + // (Fuji / Mainnet) + Duration time.Duration + + // StakeAmount is the amount of Avalanche tokens (AVAX) to stake in this validator, which is + // denominated in nAVAX. StakeAmount has to be greater than or equal to minimum stake required + // for the specified network + StakeAmount uint64 + + // DelegationFee is the percent fee this validator will charge when others delegate stake to it + // When DelegationFee is not set, the minimum delegation fee for the specified network will be set + // For more information on delegation fee, please head to https://docs.avax.network/nodes/validate/node-validator#delegation-fee-rate + DelegationFee uint32 +} + +type SubnetValidatorParams struct { + // NodeID is the unique identifier of the node to be added as a validator on the specified Subnet. + NodeID ids.NodeID + // Duration is how long the node will be staking the Subnet + // Duration has to be less than or equal to the duration that the node will be validating the Primary + // Network + Duration time.Duration + // Weight is the validator's weight when sampling validators. + // Weight for subnet validators is set to 20 by default + Weight uint64 +}