diff --git a/README.md b/README.md index abfdb9b..13a131d 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ Edit `configs/local/config.yaml` and set the necessary values. ### Run Locally: ``` -go run main.go server --config=configs/local/config.yaml +go run main.go run --config=configs/local/config.yaml ``` ## Contributing diff --git a/configs/deploy/config.sample.yaml b/configs/config.all.sample.yaml similarity index 70% rename from configs/deploy/config.sample.yaml rename to configs/config.all.sample.yaml index d85dfb3..2664b3e 100644 --- a/configs/deploy/config.sample.yaml +++ b/configs/config.all.sample.yaml @@ -10,7 +10,7 @@ MarketMaker: Slippage: 0.001 # 0.001 is 0.1% Chain: - Url: ${MARKET_MAKER_KEEPER_RPC} + Url: "YOUR_ARBITRUM_CHAIN_URL" BlockInterval: 500ms Tokens: @@ -28,28 +28,26 @@ Uniswap: Nobitex: Url: "https://api.nobitex.ir" - Key: ${NOBITEX_API_KEY} + Key: "YOUR_NOBITEX_API_KEY" MinimumOrderToman: 300_000 Timeout: 60s OrderStatusInterval: 2s RetryTimeOut: 360s RetrySleepDuration: 5s -ExecutorWallet: - PrivateKey: ${EXECUTOR_PRIVATE_KEY} Indexer: - StartBlock: ${DEX_TRADER_START_BLOCK} + StartBlock: 123 Contracts: - DexTrader: "0xb993318B8af82DbA6D30B8a459c954Cb9550714b" + DexTrader: "YOUR_DEX_TRADER_CONTRACT_ADDRESS" UniswapV3Factory: "0x1F98431c8aD98523631AE4a59f267346ea31F984" UniswapV3Quoter: "0xb27308f9F90D607463bb33eA1BeBb41C27CE5AB6" Postgres: - Host: ${ZARBAN_MAINNET_DB_HOST} - Port: ${ZARBAN_MAINNET_DB_PORT} - User: ${ZARBAN_MAINNET_DB_USER} - Password: ${ZARBAN_MAINNET_DB_PASS} - DB: ${DB_NAME} + Host: "localhost" + Port: 5432 + User: "postgres" + Password: "postgres" + DB: "postgres" MigrationsPath: '/migrations' diff --git a/configs/config.go b/configs/config.go new file mode 100644 index 0000000..0de6e09 --- /dev/null +++ b/configs/config.go @@ -0,0 +1,125 @@ +package configs + +import ( + "log" + "time" + + "github.com/mitchellh/mapstructure" + "github.com/spf13/viper" +) + +const ( + EnvironmentMainnet = "mainnet" + EnvironmentTestnet = "testnet" +) + +type General struct { + Environment string `yaml:"Environment"` + LogLevel string `yaml:"LogLevel"` +} + +type MarketMaker struct { + StartQty float64 `yaml:"StartQty"` + StepQty float64 `yaml:"StepQty"` + EndQty int64 `yaml:"EndQty"` + ProfitThreshold int64 `yaml:"ProfitThreshold"` + Interval time.Duration `yaml:"Interval"` + Slippage float64 `yaml:"Slippage"` +} + +type Chain struct { + Url string `yaml:"Url"` + BlockInterval time.Duration `yaml:"BlockInterval"` +} + +type Token struct { + Address string `yaml:"Address"` + Decimals int `yaml:"Decimals"` + Symbol string `yaml:"Symbol"` +} + +type Uniswap struct { + PoolFee float64 `yaml:"PoolFee"` +} + +type Nobitex struct { + Url string `yaml:"Url"` + Key string `yaml:"Key"` + MinimumOrderToman int64 `yaml:"MinimumOrderToman"` + Timeout time.Duration `yaml:"Timeout"` + OrderStatusInterval time.Duration `yaml:"OrderStatusInterval"` + RetryTimeOut time.Duration `yaml:"RetryTimeOut"` + RetrySleepDuration time.Duration `yaml:"RetrySleepDuration"` +} + +type Contracts struct { + DexTrader string `yaml:"DexTrader"` + UniswapV3Factory string `yaml:"UniswapV3Factory"` + UniswapV3Quoter string `yaml:"UniswapV3Quoter"` +} + +type Indexer struct { + StartBlock uint64 `yaml:"StartBlock"` +} + +type Postgres struct { + Host string `yaml:"Host"` + Port int `yaml:"Port"` + User string `yaml:"User"` + Password string `yaml:"Password"` + DB string `yaml:"DB"` + MigrationsPath string `yaml:"MigrationsPath"` +} + +type Config struct { + General General `yaml:"General"` + MarketMaker MarketMaker `yaml:"MarketMaker"` + Chain Chain `yaml:"Chain"` + Tokens []Token `yaml:"Tokens"` + Uniswap Uniswap `yaml:"Uniswap"` + Nobitex Nobitex `yaml:"Nobitex"` + Contracts Contracts `yaml:"Contracts"` + Indexer Indexer `yaml:"Indexer"` + Postgres Postgres `yaml:"Postgres"` +} + +func ReadConfig(configFile string) Config { + defaultConfig := DefaultConfig() + + c := &Config{} + *c = defaultConfig + + err := c.Unmarshal(c, configFile) + if err != nil { + log.Fatalf("Unmarshal: %v", err) + } + return *c +} + +func (c *Config) Unmarshal(rawVal interface{}, fileName string) error { + viper.SetConfigFile(fileName) + err := viper.ReadInConfig() + if err != nil { + return err + } + var input interface{} = viper.AllSettings() + config := defaultDecoderConfig(rawVal) + decoder, err := mapstructure.NewDecoder(config) + if err != nil { + return err + } + return decoder.Decode(input) +} + +func defaultDecoderConfig(output interface{}) *mapstructure.DecoderConfig { + c := &mapstructure.DecoderConfig{ + Metadata: nil, + Result: output, + WeaklyTypedInput: true, + DecodeHook: mapstructure.ComposeDecodeHookFunc( + mapstructure.StringToTimeDurationHookFunc(), + mapstructure.StringToSliceHookFunc(","), + ), + } + return c +} diff --git a/configs/config.minimal.sample.yaml b/configs/config.minimal.sample.yaml new file mode 100644 index 0000000..c94db83 --- /dev/null +++ b/configs/config.minimal.sample.yaml @@ -0,0 +1,8 @@ +Chain: + Url: "YOUR_ARBITRUM_CHAIN_URL" + +Nobitex: + Key: "YOUR_NOBITEX_API_KEY" + +Contracts: + DexTrader: "YOUR_DEX_TRADER_CONTRACT_ADDRESS" diff --git a/configs/default.go b/configs/default.go new file mode 100644 index 0000000..72513a6 --- /dev/null +++ b/configs/default.go @@ -0,0 +1,64 @@ +package configs + +import ( + "time" +) + +func DefaultConfig() Config { + return Config{ + General: General{ + Environment: EnvironmentMainnet, + LogLevel: "info", + }, + MarketMaker: MarketMaker{ + StartQty: 10.0, + StepQty: 20.0, + EndQty: 400, // max trade DAI in strategy0 and strategy1 + ProfitThreshold: 50000, // 50_000 TMN + Interval: time.Minute * 10, + Slippage: 0.001, + }, + Chain: Chain{ + BlockInterval: time.Millisecond * 500, + }, + Tokens: []Token{ + { + Address: "0xd946188a614a0d9d0685a60f541bba1e8cc421ae", + Decimals: 18, + Symbol: "ZAR", + }, + { + Address: "0xda10009cbd5d07dd0cecc66161fc93d7c9000da1", + Decimals: 18, + Symbol: "DAI", + }, + }, + Uniswap: Uniswap{ + PoolFee: 0.01, + }, + Nobitex: Nobitex{ + Url: "https://api.nobitex.ir", + Key: "", // Assuming no default value for Key + MinimumOrderToman: 300000, + Timeout: time.Second * 60, // 60s + OrderStatusInterval: time.Second * 2, // 2s + RetryTimeOut: time.Second * 360, // 360s + RetrySleepDuration: time.Second * 5, // 5s + }, + Contracts: Contracts{ + UniswapV3Factory: "0x1F98431c8aD98523631AE4a59f267346ea31F984", + UniswapV3Quoter: "0xb27308f9F90D607463bb33eA1BeBb41C27CE5AB6", + }, + Indexer: Indexer{ + StartBlock: 247010149, + }, + Postgres: Postgres{ + Host: "localhost", + Port: 5432, + User: "postgres", + Password: "postgres", + DB: "postgres", + MigrationsPath: "/migrations", + }, + } +} diff --git a/dextrader/hardhat.config.js b/dextrader/hardhat.config.js index 2d08942..9398b00 100644 --- a/dextrader/hardhat.config.js +++ b/dextrader/hardhat.config.js @@ -1,6 +1,8 @@ require("@nomicfoundation/hardhat-toolbox"); -/** @type import('hardhat/config').HardhatUserConfig */ +const PRIVATE_KEY = process.env.PRIVATE_KEY; +const ARBISCAN_KEY = process.env.ARBISCAN_KEY; + module.exports = { solidity: { version: "0.8.17", @@ -8,4 +10,26 @@ module.exports = { optimizer: { enabled: true, runs: 200 }, }, }, + etherscan: { + apiKey: { + arb: ARBISCAN_KEY, + }, + customChains: [ + { + network: "arb", + chainId: 42161, + urls: { + apiURL: "https://api.arbiscan.io/api", + browserURL: "https://arbiscan.io", + }, + }, + ], + }, + networks: { + arb: { + url: "https://1rpc.io/arb", + chainId: 42161, + accounts: [PRIVATE_KEY], + }, + }, }; diff --git a/dextrader/package-lock.json b/dextrader/package-lock.json index b328ad7..dad686d 100644 --- a/dextrader/package-lock.json +++ b/dextrader/package-lock.json @@ -9,7 +9,8 @@ "@openzeppelin/contracts": "^4.7.3", "@uniswap/v3-periphery": "^1.4.3", "bignumber.js": "^9.1.1", - "dotenv": "^16.0.3" + "dotenv": "^16.0.3", + "qrcode-terminal": "^0.12.0" }, "devDependencies": { "@nomicfoundation/hardhat-toolbox": "^2.0.0", @@ -5331,6 +5332,14 @@ "dev": true, "peer": true }, + "node_modules/qrcode-terminal": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/qrcode-terminal/-/qrcode-terminal-0.12.0.tgz", + "integrity": "sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ==", + "bin": { + "qrcode-terminal": "bin/qrcode-terminal.js" + } + }, "node_modules/qs": { "version": "6.13.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", diff --git a/dextrader/package.json b/dextrader/package.json index c6cfea8..7383dad 100644 --- a/dextrader/package.json +++ b/dextrader/package.json @@ -8,6 +8,7 @@ "@openzeppelin/contracts": "^4.7.3", "@uniswap/v3-periphery": "^1.4.3", "bignumber.js": "^9.1.1", - "dotenv": "^16.0.3" + "dotenv": "^16.0.3", + "qrcode-terminal": "^0.12.0" } } diff --git a/dextrader/scripts/account.js b/dextrader/scripts/account.js new file mode 100644 index 0000000..632f2b0 --- /dev/null +++ b/dextrader/scripts/account.js @@ -0,0 +1,28 @@ +const { ethers } = require("hardhat"); +const qrcode = require('qrcode-terminal'); // Import the qrcode-terminal package + +require('dotenv').config(); + +async function main() { + const [executor] = await ethers.getSigners(); + console.log(`Deploying contracts with the account: ${executor.address}`); + console.log(`Account balance: ${ethers.utils.formatEther(await executor.getBalance())} ETH`); + + // Generate and display the QR code for the address + qrcode.generate(executor.address, { small: true }, (qrCode) => { + console.log('\nAccount Address QR Code:\n'); + console.log(qrCode); + }); + + console.log('\nCaution!'); + console.log('To fund this account, transfer ETH to the following address on the Arbitrum network:'); + console.log(`\n${executor.address}\n`); + console.log('Scan the QR code above with your wallet to send ETH.'); + console.log('Ensure you are connected to the Arbitrum network when making the transfer.'); + console.log('Do not send ETH to this address on any other network.'); +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/dextrader/scripts/deploy.js b/dextrader/scripts/deploy.js new file mode 100644 index 0000000..8350f8f --- /dev/null +++ b/dextrader/scripts/deploy.js @@ -0,0 +1,25 @@ +const { ethers } = require("hardhat"); +const { verifyArbiscanContract } = require("./verify"); + +require('dotenv').config() + +const UniswapV3Router = '0xE592427A0AEce92De3Edee1F18E0157C05861564'; + +async function main() { + const [executor] = await ethers.getSigners(); + console.log(`Deploying contracts with the account: ${executor.address}`); + console.log(`Account balance: ${ethers.utils.formatEther(await executor.getBalance())} ETH`); + + const DexTrader = await ethers.getContractFactory("DexTrader"); + const dexTrader = await DexTrader.deploy(UniswapV3Router, executor.address); + + await dexTrader.deployed(); + console.log(`DexTrader contract deployed to ${dexTrader.address}`); + + await verifyArbiscanContract(dexTrader.address, [UniswapV3Router, executor.address]) +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/dextrader/scripts/verify.js b/dextrader/scripts/verify.js new file mode 100644 index 0000000..1a52396 --- /dev/null +++ b/dextrader/scripts/verify.js @@ -0,0 +1,76 @@ +const hre = require("hardhat"); + +const fatalErrors = [ + "The address provided as argument contains a contract, but its bytecode", + "Daily limit of 100 source code submissions reached", + "has no bytecode. Is the contract deployed to this network", + "The constructor for", +]; + +const okErrors = ["Contract source code already verified"]; + +const unableVerifyError = 'Fail - Unable to verify'; + +function delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +const verifyArbiscanContract = async (contractAddress, params) => { + try { + console.log('Verifying deployed contract'); + const msDelay = 3000; + const times = 4; + + await verifyWithRetry(contractAddress, params, times, msDelay); + } catch (error) { + console.error('[ERROR] Failed to verify contract:', error.message); + } +} + +const verifyWithRetry = async (contractAddress, params, times, msDelay) => { + let counter = times; + await delay(msDelay); + + try { + if (times > 1) { + await verify(contractAddress, params); + } else if (times === 1) { + console.log('Trying to verify via uploading all sources.'); + await verify(contractAddress, params); + } else { + console.error('[ERROR] Errors after all the retries, check the logs for more information.'); + } + } catch (error) { + counter--; + + if (okErrors.some((okReason) => error.message.includes(okReason))) { + console.info('Skipping due OK response: ', error.message); + return; + } + + if (fatalErrors.some((fatalError) => error.message.includes(fatalError))) { + console.error('[ERROR] Fatal error detected, skip retries and resume deployment.', error.message); + return; + } + + console.error('[ERROR]', error.message); + console.log(); + console.info(`[INFO] Retrying attempts: ${counter}.`); + if (error.message.includes(unableVerifyError)) { + console.log('Trying to verify via uploading all sources.'); + params.relatedSources = undefined; + } + await verifyWithRetry(contractAddress, params, counter, msDelay); + } +} + +const verify = async (address, params) => { + return hre.run("verify:verify", { + address: address, + constructorArguments: params, + }); +} + +module.exports = { + verifyArbiscanContract, +}; diff --git a/internal/cmd/run/run.go b/internal/cmd/run/run.go index 3d928ac..cc49ccd 100644 --- a/internal/cmd/run/run.go +++ b/internal/cmd/run/run.go @@ -13,9 +13,9 @@ import ( "github.com/shopspring/decimal" "github.com/spf13/cobra" - block_ptr "github.com/zarbanio/market-maker-keeper/internal/block-ptr" + "github.com/zarbanio/market-maker-keeper/configs" + blockptr "github.com/zarbanio/market-maker-keeper/internal/block-ptr" "github.com/zarbanio/market-maker-keeper/internal/chain" - "github.com/zarbanio/market-maker-keeper/internal/configs" "github.com/zarbanio/market-maker-keeper/internal/dextrader" "github.com/zarbanio/market-maker-keeper/internal/domain" "github.com/zarbanio/market-maker-keeper/internal/domain/pair" @@ -31,11 +31,6 @@ import ( "github.com/zarbanio/market-maker-keeper/store" ) -const ( - EnvironmentMainnet = "mainnet" - EnvironmentTestnet = "testnet" -) - func main(cfg configs.Config) { postgresStore := store.NewPostgres(cfg.Postgres.Host, cfg.Postgres.Port, cfg.Postgres.User, cfg.Postgres.Password, cfg.Postgres.DB) err := postgresStore.Migrate(cfg.Postgres.MigrationsPath) @@ -47,7 +42,7 @@ func main(cfg configs.Config) { log.Panic(err) } - blockPtr := block_ptr.NewDBBlockPointer(postgresStore, cfg.Indexer.StartBlock) + blockPtr := blockptr.NewDBBlockPointer(postgresStore, cfg.Indexer.StartBlock) if !blockPtr.Exists() { logger.Logger.Debug().Msg("block pointer doest not exits. creating a new one") err := blockPtr.Create() @@ -58,7 +53,12 @@ func main(cfg configs.Config) { logger.Logger.Debug().Uint64("start block", cfg.Indexer.StartBlock).Msg("new block pointer created.") } - executorWallet, err := keystore.New(os.Getenv("PRIVATE_KEY")) + privateKey := os.Getenv("PRIVATE_KEY") + if privateKey == "" { + logger.Logger.Fatal().Msg("PRIVATE_KEY environment variable is not set") + } + + executorWallet, err := keystore.New(privateKey) if err != nil { logger.Logger.Fatal().Err(err).Msg("error while initializing new executor wallet") } @@ -93,17 +93,17 @@ func main(cfg configs.Config) { uniswapV3Factory := uniswapv3.NewFactory(eth, common.HexToAddress(cfg.Contracts.UniswapV3Factory)) - DAI, err := tokenStore.GetTokenBySymbol(symbol.DAI) + dai, err := tokenStore.GetTokenBySymbol(symbol.DAI) if err != nil { logger.Logger.Panic().Err(err).Msg("error while getting token by symbol") } - ZAR, err := tokenStore.GetTokenBySymbol(symbol.ZAR) + zar, err := tokenStore.GetTokenBySymbol(symbol.ZAR) if err != nil { logger.Logger.Panic().Err(err).Msg("error while getting token by symbol") } // crate pair in database if not exist - botPair := pair.Pair{QuoteAsset: DAI.Symbol(), BaseAsset: ZAR.Symbol()} + botPair := pair.Pair{QuoteAsset: dai.Symbol(), BaseAsset: zar.Symbol()} pairId, err := postgresStore.CreatePairIfNotExist(context.Background(), &botPair) if err != nil { logger.Logger.Panic().Err(err).Msg("error while creating pair") @@ -111,7 +111,7 @@ func main(cfg configs.Config) { botPair.Id = pairId poolFee := domain.ParseUniswapFee(cfg.Uniswap.PoolFee) - _, err = uniswapV3Factory.GetPool(context.Background(), DAI.Address(), ZAR.Address(), poolFee) + _, err = uniswapV3Factory.GetPool(context.Background(), dai.Address(), zar.Address(), poolFee) if err != nil { logger.Logger.Panic().Err(err).Msg("error while getting pool from uniswapV3") } @@ -134,7 +134,7 @@ func main(cfg configs.Config) { cfg.Nobitex.OrderStatusInterval, ) - if cfg.General.Environment == EnvironmentTestnet { + if cfg.General.Environment == configs.EnvironmentTestnet { nobitexExchange = nobitex.NewMockExchange( cfg.Nobitex.Url, cfg.Nobitex.Timeout, @@ -148,10 +148,10 @@ func main(cfg configs.Config) { } tokens := make(map[symbol.Symbol]domain.Token) - tokens[symbol.DAI] = DAI - tokens[symbol.ZAR] = ZAR + tokens[symbol.DAI] = dai + tokens[symbol.ZAR] = zar - configStrategy := strategy.Config{ + strategyConfig := strategy.Config{ StartQty: decimal.NewFromFloat(cfg.MarketMaker.StartQty), StepQty: decimal.NewFromFloat(cfg.MarketMaker.StepQty), EndQty: decimal.NewFromInt(cfg.MarketMaker.EndQty), @@ -159,8 +159,8 @@ func main(cfg configs.Config) { Slippage: decimal.NewFromFloat(cfg.MarketMaker.Slippage), } - buyDaiInUniswapSellTetherInNobitex := strategy.NewBuyDaiUniswapSellTetherNobitex(postgresStore, nobitexExchange, dexTrader, quoter, tokens, configStrategy) - buyTetherInNobitexSellDaiInUniswap := strategy.NewSellDaiUniswapBuyTetherNobitex(postgresStore, nobitexExchange, dexTrader, quoter, tokens, configStrategy) + buyDaiInUniswapSellTetherInNobitex := strategy.NewBuyDaiUniswapSellTetherNobitex(postgresStore, nobitexExchange, dexTrader, quoter, tokens, strategyConfig) + buyTetherInNobitexSellDaiInUniswap := strategy.NewSellDaiUniswapBuyTetherNobitex(postgresStore, nobitexExchange, dexTrader, quoter, tokens, strategyConfig) ctx := context.Background() diff --git a/internal/configs/config.go b/internal/configs/config.go deleted file mode 100644 index 4a08a26..0000000 --- a/internal/configs/config.go +++ /dev/null @@ -1,98 +0,0 @@ -package configs - -import ( - "log" - "time" - - "github.com/mitchellh/mapstructure" - "github.com/spf13/viper" -) - -type Config struct { - General struct { - Environment string `yaml:"Environment"` - LogLevel string `yaml:"LogLevel"` - } `yaml:"General"` - MarketMaker struct { - StartQty float64 `yaml:"StartQty"` - StepQty float64 `yaml:"StepQty"` - EndQty int64 `yaml:"EndQty"` - ProfitThreshold int64 `yaml:"ProfitThreshold"` - Interval time.Duration `yaml:"Interval"` - Slippage float64 `yaml:"Slippage"` - } `yaml:"MarketMaker"` - Chain struct { - Url string `yaml:"Url"` - BlockInterval time.Duration `yaml:"BlockInterval"` - } `yaml:"Chain"` - Tokens []struct { - Address string `yaml:"Address"` - Decimals int `yaml:"Decimals"` - Symbol string `yaml:"Symbol"` - } `yaml:"Tokens"` - Uniswap struct { - PoolFee float64 `yaml:"PoolFee"` - } - Nobitex struct { - Url string `yaml:"Url"` - Key string `yaml:"Key"` - MinimumOrderToman int64 `yaml:"MinimumOrderToman"` - Timeout time.Duration `yaml:"Timeout"` - OrderStatusInterval time.Duration `yaml:"OrderStatusInterval"` - RetryTimeOut time.Duration `yaml:"RetryTimeOut"` - RetrySleepDuration time.Duration `yml:"RetrySleepDuration"` - } `yaml:"nobitex"` - Contracts struct { - DexTrader string `yaml:"DexTrader"` - UniswapV3Factory string `yaml:"UniswapV3Factory"` - UniswapV3Quoter string `yaml:"UniswapV3Quoter"` - } `yaml:"Contracts"` - Indexer struct { - StartBlock uint64 `yaml:"StartBlock"` - } - Postgres struct { - Host string `yaml:"Host"` - Port int `yaml:"Port"` - User string `yaml:"User"` - Password string `yaml:"Password"` - DB string `yaml:"DB"` - MigrationsPath string `yaml:"MigrationsPath"` - } `yaml:"Postgres"` -} - -func ReadConfig(configFile string) Config { - c := &Config{} - err := c.Unmarshal(c, configFile) - if err != nil { - log.Fatalf("Unmarshal: %v", err) - } - return *c -} - -func (c *Config) Unmarshal(rawVal interface{}, fileName string) error { - viper.SetConfigFile(fileName) - err := viper.ReadInConfig() - if err != nil { - return err - } - var input interface{} = viper.AllSettings() - config := defaultDecoderConfig(rawVal) - decoder, err := mapstructure.NewDecoder(config) - if err != nil { - return err - } - return decoder.Decode(input) -} - -func defaultDecoderConfig(output interface{}) *mapstructure.DecoderConfig { - c := &mapstructure.DecoderConfig{ - Metadata: nil, - Result: output, - WeaklyTypedInput: true, - DecodeHook: mapstructure.ComposeDecodeHookFunc( - mapstructure.StringToTimeDurationHookFunc(), - mapstructure.StringToSliceHookFunc(","), - ), - } - return c -}