From 65f910958dcba8d82dc9a79230bc5bdcc8d5c278 Mon Sep 17 00:00:00 2001 From: Justin Brower Date: Fri, 6 Sep 2024 17:53:02 -0400 Subject: [PATCH 1/2] support multicall3 in status --- cli/core/checkpoint.go | 2 +- cli/core/multicall/multicall.go | 229 ++++++++++++++++++++++++++++++++ cli/core/status.go | 2 +- cli/core/utils.go | 83 +++++++++--- cli/core/validator.go | 6 +- 5 files changed, 298 insertions(+), 24 deletions(-) create mode 100644 cli/core/multicall/multicall.go diff --git a/cli/core/checkpoint.go b/cli/core/checkpoint.go index b573e82c..d8ab7f88 100644 --- a/cli/core/checkpoint.go +++ b/cli/core/checkpoint.go @@ -187,7 +187,7 @@ func GenerateCheckpointProofForState(ctx context.Context, eigenpodAddress string color.Yellow("You have a total of %d validators pointed to this pod.", len(allValidators)) } - allValidatorsWithInfo, err := FetchMultipleOnchainValidatorInfo(eth, eigenpodAddress, allValidators) + allValidatorsWithInfo, err := FetchMultipleOnchainValidatorInfo(ctx, eth, eigenpodAddress, allValidators) if err != nil { return nil, err } diff --git a/cli/core/multicall/multicall.go b/cli/core/multicall/multicall.go new file mode 100644 index 00000000..e7d0e06d --- /dev/null +++ b/cli/core/multicall/multicall.go @@ -0,0 +1,229 @@ +package multicall + +import ( + "context" + "errors" + "fmt" + "math" + "strings" + + "github.com/Layr-Labs/eigenpod-proofs-generation/cli/utils" + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/ethclient" +) + +type MultiCallMetaData[T interface{}] struct { + Address common.Address + Data []byte + Deserialize func([]byte) (T, error) +} + +type Multicall3Result struct { + Success bool + ReturnData []byte +} + +type DeserializedMulticall3Result struct { + Success bool + Value any +} + +func (md *MultiCallMetaData[T]) Raw() RawMulticall { + return RawMulticall{ + Address: md.Address, + Data: md.Data, + Deserialize: func(data []byte) (any, error) { + res, err := md.Deserialize(data) + return any(res), err + }, + } +} + +type RawMulticall struct { + Address common.Address + Data []byte + Deserialize func([]byte) (any, error) +} + +type MulticallContract struct { + Contract *bind.BoundContract + ABI *abi.ABI + Context context.Context + MaxBatchSize uint64 +} + +type ParamMulticall3Call3 struct { + Target common.Address + AllowFailure bool + CallData []byte +} + +// maxBatchSizeBytes - 0: no batching. +func NewMulticallContract(ctx context.Context, eth *ethclient.Client, address *common.Address, maxBatchSizeBytes uint64) (*MulticallContract, error) { + if eth == nil { + return nil, errors.New("no ethclient passed") + } + + // taken from: https://www.multicall3.com/ + parsed, err := abi.JSON(strings.NewReader(`[{"inputs":[{"components":[{"internalType":"address","name":"target","type":"address"},{"internalType":"bytes","name":"callData","type":"bytes"}],"internalType":"struct Multicall3.Call[]","name":"calls","type":"tuple[]"}],"name":"aggregate","outputs":[{"internalType":"uint256","name":"blockNumber","type":"uint256"},{"internalType":"bytes[]","name":"returnData","type":"bytes[]"}],"stateMutability":"payable","type":"function"},{"inputs":[{"components":[{"internalType":"address","name":"target","type":"address"},{"internalType":"bool","name":"allowFailure","type":"bool"},{"internalType":"bytes","name":"callData","type":"bytes"}],"internalType":"struct Multicall3.Call3[]","name":"calls","type":"tuple[]"}],"name":"aggregate3","outputs":[{"components":[{"internalType":"bool","name":"success","type":"bool"},{"internalType":"bytes","name":"returnData","type":"bytes"}],"internalType":"struct Multicall3.Result[]","name":"returnData","type":"tuple[]"}],"stateMutability":"payable","type":"function"},{"inputs":[{"components":[{"internalType":"address","name":"target","type":"address"},{"internalType":"bool","name":"allowFailure","type":"bool"},{"internalType":"uint256","name":"value","type":"uint256"},{"internalType":"bytes","name":"callData","type":"bytes"}],"internalType":"struct Multicall3.Call3Value[]","name":"calls","type":"tuple[]"}],"name":"aggregate3Value","outputs":[{"components":[{"internalType":"bool","name":"success","type":"bool"},{"internalType":"bytes","name":"returnData","type":"bytes"}],"internalType":"struct Multicall3.Result[]","name":"returnData","type":"tuple[]"}],"stateMutability":"payable","type":"function"},{"inputs":[{"components":[{"internalType":"address","name":"target","type":"address"},{"internalType":"bytes","name":"callData","type":"bytes"}],"internalType":"struct Multicall3.Call[]","name":"calls","type":"tuple[]"}],"name":"blockAndAggregate","outputs":[{"internalType":"uint256","name":"blockNumber","type":"uint256"},{"internalType":"bytes32","name":"blockHash","type":"bytes32"},{"components":[{"internalType":"bool","name":"success","type":"bool"},{"internalType":"bytes","name":"returnData","type":"bytes"}],"internalType":"struct Multicall3.Result[]","name":"returnData","type":"tuple[]"}],"stateMutability":"payable","type":"function"},{"inputs":[],"name":"getBasefee","outputs":[{"internalType":"uint256","name":"basefee","type":"uint256"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"uint256","name":"blockNumber","type":"uint256"}],"name":"getBlockHash","outputs":[{"internalType":"bytes32","name":"blockHash","type":"bytes32"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"getBlockNumber","outputs":[{"internalType":"uint256","name":"blockNumber","type":"uint256"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"getChainId","outputs":[{"internalType":"uint256","name":"chainid","type":"uint256"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"getCurrentBlockCoinbase","outputs":[{"internalType":"address","name":"coinbase","type":"address"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"getCurrentBlockDifficulty","outputs":[{"internalType":"uint256","name":"difficulty","type":"uint256"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"getCurrentBlockGasLimit","outputs":[{"internalType":"uint256","name":"gaslimit","type":"uint256"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"getCurrentBlockTimestamp","outputs":[{"internalType":"uint256","name":"timestamp","type":"uint256"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"address","name":"addr","type":"address"}],"name":"getEthBalance","outputs":[{"internalType":"uint256","name":"balance","type":"uint256"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"getLastBlockHash","outputs":[{"internalType":"bytes32","name":"blockHash","type":"bytes32"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"bool","name":"requireSuccess","type":"bool"},{"components":[{"internalType":"address","name":"target","type":"address"},{"internalType":"bytes","name":"callData","type":"bytes"}],"internalType":"struct Multicall3.Call[]","name":"calls","type":"tuple[]"}],"name":"tryAggregate","outputs":[{"components":[{"internalType":"bool","name":"success","type":"bool"},{"internalType":"bytes","name":"returnData","type":"bytes"}],"internalType":"struct Multicall3.Result[]","name":"returnData","type":"tuple[]"}],"stateMutability":"payable","type":"function"},{"inputs":[{"internalType":"bool","name":"requireSuccess","type":"bool"},{"components":[{"internalType":"address","name":"target","type":"address"},{"internalType":"bytes","name":"callData","type":"bytes"}],"internalType":"struct Multicall3.Call[]","name":"calls","type":"tuple[]"}],"name":"tryBlockAndAggregate","outputs":[{"internalType":"uint256","name":"blockNumber","type":"uint256"},{"internalType":"bytes32","name":"blockHash","type":"bytes32"},{"components":[{"internalType":"bool","name":"success","type":"bool"},{"internalType":"bytes","name":"returnData","type":"bytes"}],"internalType":"struct Multicall3.Result[]","name":"returnData","type":"tuple[]"}],"stateMutability":"payable","type":"function"}]`)) + if err != nil { + return nil, fmt.Errorf("error parsing multicall abi: %s", err.Error()) + } + + contractAddress := func() common.Address { + if address == nil { + // also taken from: https://www.multicall3.com/ -- it's deployed at the same addr on most chains + return common.HexToAddress("0xcA11bde05977b3631167028862bE2a173976CA11") + } + return *address + }() + + return &MulticallContract{MaxBatchSize: maxBatchSizeBytes, Context: ctx, ABI: &parsed, Contract: bind.NewBoundContract(contractAddress, parsed, eth, eth, eth)}, nil +} + +// Call invokes the (constant) contract method with params as input values and +// sets the output to result. The result type might be a single field for simple +// returns, a slice of interfaces for anonymous returns and a struct for named +// returns. +func MultiCall[T any](contractAddress common.Address, abi abi.ABI, deserialize func([]byte) (T, error), method string, params ...interface{}) (*MultiCallMetaData[T], error) { + callData, err := abi.Pack(method, params...) + if err != nil { + return nil, fmt.Errorf("error packing multicall: %s", err.Error()) + } + return &MultiCallMetaData[T]{ + Address: contractAddress, + Data: callData, + Deserialize: deserialize, + }, nil +} + +func DoMultiCall[A any, B any](mc MulticallContract, a *MultiCallMetaData[A], b *MultiCallMetaData[B]) (*A, *B, error) { + res, err := doMultiCallMany(mc, a.Raw(), b.Raw()) + if err != nil { + return nil, nil, fmt.Errorf("error performing multicall: %s", err.Error()) + } + return any(res[0].Value).(*A), any(res[1].Value).(*B), nil +} + +func DoMultiCallMany[A any](mc MulticallContract, requests ...*MultiCallMetaData[A]) (*[]A, error) { + res, err := doMultiCallMany(mc, utils.Map(requests, func(mc *MultiCallMetaData[A], index uint64) RawMulticall { + return mc.Raw() + })...) + if err != nil { + return nil, fmt.Errorf("multicall failed: %s", err.Error()) + } + + // unwind results + unwoundResults := utils.Map(res, func(d DeserializedMulticall3Result, i uint64) A { + // force these back to A + return any(d.Value).(A) + }) + return &unwoundResults, nil +} + +/* + * Some RPC providers may limit the amount of calldata you can send in one eth_call, which (for those who have 1000's of validators), means + * you can't just spam one enormous multicall request. + * + * This function checks whether the calldata appended exceeds maxBatchSizeBytes + */ +func chunkCalls(allCalls []ParamMulticall3Call3, maxBatchSizeBytes int) [][]ParamMulticall3Call3 { + // chunk by the maximum size of calldata, which is 1024 per call. + results := [][]ParamMulticall3Call3{} + currentBatchSize := 0 + currentBatch := []ParamMulticall3Call3{} + + for _, call := range allCalls { + if (currentBatchSize + len(call.CallData)) > maxBatchSizeBytes { + // we can't fit in this batch, so dump the current batch and start a new one + results = append(results, currentBatch) + currentBatchSize = 0 + currentBatch = []ParamMulticall3Call3{} + } + + currentBatch = append(currentBatch, call) + currentBatchSize += len(call.CallData) + } + + // check if we forgot to add the last batch + if len(currentBatch) > 0 { + results = append(results, currentBatch) + } + + return results +} + +func doMultiCallMany(mc MulticallContract, calls ...RawMulticall) ([]DeserializedMulticall3Result, error) { + typedCalls := make([]ParamMulticall3Call3, len(calls)) + for i, call := range calls { + typedCalls[i] = ParamMulticall3Call3{ + Target: call.Address, + AllowFailure: true, + CallData: call.Data, + } + } + + // see if we need to chunk them now + chunkedCalls := chunkCalls(typedCalls, func() int { + if mc.MaxBatchSize == 0 { + return math.MaxInt64 + } else { + return int(mc.MaxBatchSize) + } + }()) + var results = make([]interface{}, len(calls)) + var totalResults = 0 + + for _, multicalls := range chunkedCalls { + var res []interface{} + + err := mc.Contract.Call(&bind.CallOpts{}, &res, "aggregate3", multicalls) + if err != nil { + return nil, fmt.Errorf("aggregate3 failed: %s", err) + } + + multicallResults := *abi.ConvertType(res[0], new([]Multicall3Result)).(*[]Multicall3Result) + + // copy over into master results list + for i := 0; i < len(multicallResults); i++ { + results[totalResults+i] = multicallResults[i] + } + totalResults += len(multicallResults) + } + + // now we should have a bunch of Multicall3Result + outputs := make([]DeserializedMulticall3Result, len(calls)) + for i, call := range calls { + res := results[i].(Multicall3Result) + if res.Success { + if res.ReturnData != nil { + val, err := call.Deserialize(res.ReturnData) + if err != nil { + outputs[i] = DeserializedMulticall3Result{ + Value: err, + Success: false, + } + } else { + outputs[i] = DeserializedMulticall3Result{ + Value: val, + Success: res.Success, + } + } + } else { + outputs[i] = DeserializedMulticall3Result{ + Value: errors.New("no data returned"), + Success: false, + } + } + } else { + outputs[i] = DeserializedMulticall3Result{ + Success: false, + Value: errors.New("call failed"), + } + } + } + + return outputs, nil +} diff --git a/cli/core/status.go b/cli/core/status.go index fd208894..5bc82195 100644 --- a/cli/core/status.go +++ b/cli/core/status.go @@ -103,7 +103,7 @@ func GetStatus(ctx context.Context, eigenpodAddress string, eth *ethclient.Clien allValidatorsForEigenpod, err := FindAllValidatorsForEigenpod(eigenpodAddress, state) PanicOnError("failed to find validators", err) - allValidatorsWithInfoForEigenpod, err := FetchMultipleOnchainValidatorInfo(eth, eigenpodAddress, allValidatorsForEigenpod) + allValidatorsWithInfoForEigenpod, err := FetchMultipleOnchainValidatorInfo(ctx, eth, eigenpodAddress, allValidatorsForEigenpod) PanicOnError("failed to fetch validator info", err) allBeaconBalances := getRegularBalancesGwei(state) diff --git a/cli/core/utils.go b/cli/core/utils.go index bb4cf312..c4eef043 100644 --- a/cli/core/utils.go +++ b/cli/core/utils.go @@ -14,11 +14,15 @@ import ( "os" "sort" "strconv" + "strings" eigenpodproofs "github.com/Layr-Labs/eigenpod-proofs-generation" + "github.com/Layr-Labs/eigenpod-proofs-generation/cli/core/multicall" "github.com/Layr-Labs/eigenpod-proofs-generation/cli/core/onchain" + "github.com/Layr-Labs/eigenpod-proofs-generation/cli/utils" "github.com/attestantio/go-eth2-client/spec" "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/ethereum/go-ethereum/accounts/abi" "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/types" @@ -280,37 +284,78 @@ func FindAllValidatorsForEigenpod(eigenpodAddress string, beaconState *spec.Vers return outputValidators, nil } -func FetchMultipleOnchainValidatorInfo(client *ethclient.Client, eigenpodAddress string, allValidators []ValidatorWithIndex) ([]ValidatorWithOnchainInfo, error) { - eigenPod, err := onchain.NewEigenPod(common.HexToAddress(eigenpodAddress), client) +var zeroes = [16]byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00} + +func FetchMultipleOnchainValidatorInfo(ctx context.Context, client *ethclient.Client, eigenpodAddress string, allValidators []ValidatorWithIndex) ([]ValidatorWithOnchainInfo, error) { + eigenpodAbi, err := abi.JSON(strings.NewReader(onchain.EigenPodABI)) if err != nil { - return nil, fmt.Errorf("failed to locate Eigenpod. Is your address correct?: %w", err) + return nil, fmt.Errorf("failed to load eigenpod abi: %s", err) } - var validators []ValidatorWithOnchainInfo = []ValidatorWithOnchainInfo{} + type MulticallAndError struct { + Multicall *multicall.MultiCallMetaData[*onchain.IEigenPodValidatorInfo] + Error error + } - // TODO: batch/multicall - zeroes := [16]byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00} - for i := 0; i < len(allValidators); i++ { - // ssz requires values to be 32-byte aligned, which requires 16 bytes of 0's to be added - // prior to hashing. + requests := utils.Map(allValidators, func(validator ValidatorWithIndex, index uint64) MulticallAndError { pubKeyHash := sha256.Sum256( append( - (allValidators[i]).Validator.PublicKey[:], + validator.Validator.PublicKey[:], zeroes[:]..., ), ) - info, err := eigenPod.ValidatorPubkeyHashToInfo(nil, pubKeyHash) - if err != nil { - return nil, fmt.Errorf("failed to fetch validator eigeninfo: %w", err) + + mc, err := multicall.MultiCall(common.HexToAddress(eigenpodAddress), eigenpodAbi, func(data []byte) (*onchain.IEigenPodValidatorInfo, error) { + res, err := eigenpodAbi.Unpack("validatorPubkeyHashToInfo", data) + if err != nil { + return nil, err + } + return abi.ConvertType(res[0], new(onchain.IEigenPodValidatorInfo)).(*onchain.IEigenPodValidatorInfo), nil + }, "validatorPubkeyHashToInfo", pubKeyHash) + + return MulticallAndError{ + Multicall: mc, + Error: err, + } + }) + + errs := []error{} + for _, mc := range requests { + if mc.Error != nil { + errs = append(errs, mc.Error) } - validators = append(validators, ValidatorWithOnchainInfo{ - Index: allValidators[i].Index, - Validator: allValidators[i].Validator, - Info: info, - }) } - return validators, nil + if len(errs) > 0 { + return nil, fmt.Errorf("failed to form request for validator info: %s", errors.Join(errs...)) + } + + allMulticalls := utils.Map(requests, func(mc MulticallAndError, _ uint64) *multicall.MultiCallMetaData[*onchain.IEigenPodValidatorInfo] { + return mc.Multicall + }) + + // make the multicall requests + multicallInstance, err := multicall.NewMulticallContract(ctx, client, nil, 4096 /* no batching */) + if err != nil { + return nil, fmt.Errorf("failed to contact multicall: %s", err.Error()) + } + + results, err := multicall.DoMultiCallMany(*multicallInstance, allMulticalls...) + if err != nil { + return nil, fmt.Errorf("failed to fetch validator info: %s", err.Error()) + } + + if results == nil { + return nil, errors.New("no results returned fetching validator info") + } + + return utils.Map(*results, func(info *onchain.IEigenPodValidatorInfo, i uint64) ValidatorWithOnchainInfo { + return ValidatorWithOnchainInfo{ + Info: *info, + Validator: allValidators[i].Validator, + Index: allValidators[i].Index, + } + }), nil } func GetCurrentCheckpointBlockRoot(eigenpodAddress string, eth *ethclient.Client) (*[32]byte, error) { diff --git a/cli/core/validator.go b/cli/core/validator.go index 43daac87..3ffc5b28 100644 --- a/cli/core/validator.go +++ b/cli/core/validator.go @@ -152,11 +152,11 @@ func GenerateValidatorProof(ctx context.Context, eigenpodAddress string, eth *et return nil, 0, fmt.Errorf("failed to initialize provider: %w", err) } - proofs, err := GenerateValidatorProofAtState(proofExecutor, eigenpodAddress, beaconState, eth, chainId, header, latestBlock.Time(), validatorIndex, verbose) + proofs, err := GenerateValidatorProofAtState(ctx, proofExecutor, eigenpodAddress, beaconState, eth, chainId, header, latestBlock.Time(), validatorIndex, verbose) return proofs, latestBlock.Time(), err } -func GenerateValidatorProofAtState(proofs *eigenpodproofs.EigenPodProofs, eigenpodAddress string, beaconState *spec.VersionedBeaconState, eth *ethclient.Client, chainId *big.Int, header *v1.BeaconBlockHeader, blockTimestamp uint64, forSpecificValidatorIndex *big.Int, verbose bool) (*eigenpodproofs.VerifyValidatorFieldsCallParams, error) { +func GenerateValidatorProofAtState(ctx context.Context, proofs *eigenpodproofs.EigenPodProofs, eigenpodAddress string, beaconState *spec.VersionedBeaconState, eth *ethclient.Client, chainId *big.Int, header *v1.BeaconBlockHeader, blockTimestamp uint64, forSpecificValidatorIndex *big.Int, verbose bool) (*eigenpodproofs.VerifyValidatorFieldsCallParams, error) { allValidators, err := FindAllValidatorsForEigenpod(eigenpodAddress, beaconState) if err != nil { return nil, fmt.Errorf("failed to find validators: %w", err) @@ -177,7 +177,7 @@ func GenerateValidatorProofAtState(proofs *eigenpodproofs.EigenPodProofs, eigenp } } else { // default behavior -- load any validators that are inactive / need a credential proof - allValidatorsWithInfo, err := FetchMultipleOnchainValidatorInfo(eth, eigenpodAddress, allValidators) + allValidatorsWithInfo, err := FetchMultipleOnchainValidatorInfo(ctx, eth, eigenpodAddress, allValidators) if err != nil { return nil, fmt.Errorf("failed to load validator information: %s", err.Error()) } From 679c7710f798fa070cc930fa31600056eec5b5c9 Mon Sep 17 00:00:00 2001 From: Justin Brower Date: Fri, 6 Sep 2024 18:20:31 -0400 Subject: [PATCH 2/2] go mod tidy --- go.mod | 1 - go.sum | 2 -- 2 files changed, 3 deletions(-) diff --git a/go.mod b/go.mod index 5cab89e7..6880404a 100644 --- a/go.mod +++ b/go.mod @@ -32,7 +32,6 @@ require ( github.com/deckarep/golang-set/v2 v2.1.0 // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 // indirect github.com/ethereum/c-kzg-4844 v0.4.0 // indirect - github.com/forta-network/go-multicall v0.0.0-20230701154355-9467c4ddaa83 // indirect github.com/fsnotify/fsnotify v1.6.0 // indirect github.com/go-logr/logr v1.2.4 // indirect github.com/go-logr/stdr v1.2.2 // indirect diff --git a/go.sum b/go.sum index 61c24f26..955c3d18 100644 --- a/go.sum +++ b/go.sum @@ -63,8 +63,6 @@ github.com/ferranbt/fastssz v0.1.3 h1:ZI+z3JH05h4kgmFXdHuR1aWYsgrg7o+Fw7/NCzM16M github.com/ferranbt/fastssz v0.1.3/go.mod h1:0Y9TEd/9XuFlh7mskMPfXiI2Dkw4Ddg9EyXt1W7MRvE= github.com/fjl/memsize v0.0.2 h1:27txuSD9or+NZlnOWdKUxeBzTAUkWCVh+4Gf2dWFOzA= github.com/fjl/memsize v0.0.2/go.mod h1:VvhXpOYNQvB+uIk2RvXzuaQtkQJzzIx6lSBe1xv7hi0= -github.com/forta-network/go-multicall v0.0.0-20230701154355-9467c4ddaa83 h1:aVJgFjILhAM3q1h2PVVRJkUAVBPteDNo2cjhQLzCvp0= -github.com/forta-network/go-multicall v0.0.0-20230701154355-9467c4ddaa83/go.mod h1:nqTUF1REklpWLZ/M5HfzqhSHNz4dPVKzJvbLziqTZpw= github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= github.com/gballet/go-libpcsclite v0.0.0-20190607065134-2772fd86a8ff h1:tY80oXqGNY4FhTFhk+o9oFHGINQ/+vhlm8HFzi6znCI=