diff --git a/client/Main.hs b/client/Main.hs index 6933fbcb..b3bb65e5 100644 --- a/client/Main.hs +++ b/client/Main.hs @@ -223,6 +223,22 @@ currentProfileParser :: Parser ProfileCommand currentProfileParser = hsubparser ( command "get" (GetCurrentProfile <$> getCurrentProfileInfo) <> command "update" (UpdateCurrentProfile <$> updateCurrentProfileInfo) + <> command "get-wallet-address" (GetProfileWalletAddress <$> getWalletAddressInfo) + <> command "get-balance" (GetProfileBalance <$> getBalanceInfo) + ) + +getBalanceInfo :: ParserInfo Auth +getBalanceInfo = info authParser + ( fullDesc + <> header "plutus-certification-client profile get-balance — Get the balance of the current profile" + ) + + + +getWalletAddressInfo :: ParserInfo Auth +getWalletAddressInfo = info authParser + ( fullDesc + <> header "plutus-certification-client profile get-wallet-address — Get the wallet address of the current profile" ) updateCurrentProfileInfo :: ParserInfo UpdateCurrentProfileArgs @@ -384,6 +400,8 @@ data GetRepositoryInfoArgs = GetGitHubAddressArgs data ProfileCommand = GetCurrentProfile !Auth | UpdateCurrentProfile !UpdateCurrentProfileArgs + | GetProfileWalletAddress !Auth + | GetProfileBalance !Auth data UpdateCurrentProfileArgs = UpdateCurrentProfileArgs !Auth !ProfileBody @@ -582,6 +600,10 @@ main = do withAuth auth $ \c authKey -> c.getCurrentProfile authKey CmdCurrentProfile (UpdateCurrentProfile (UpdateCurrentProfileArgs auth profileBody)) -> withAuth auth $ \c authKey -> c.updateCurrentProfile authKey profileBody + CmdCurrentProfile (GetProfileWalletAddress auth) -> + withAuth auth $ \c authKey -> c.getProfileWalletAddress authKey + CmdCurrentProfile (GetProfileBalance auth) -> + withAuth auth $ \c authKey -> c.getProfileBalance authKey CmdLogin loginBody -> do handle $ apiClient.login loginBody CmdServerTimestamp -> diff --git a/dapps-certification-persistence/dapps-certification-persistence.cabal b/dapps-certification-persistence/dapps-certification-persistence.cabal index aaba4e16..0c8439d1 100644 --- a/dapps-certification-persistence/dapps-certification-persistence.cabal +++ b/dapps-certification-persistence/dapps-certification-persistence.cabal @@ -22,5 +22,6 @@ library , aeson , swagger2 , lens + , containers hs-source-dirs: src default-language: Haskell2010 diff --git a/dapps-certification-persistence/src/IOHK/Certification/Persistence.hs b/dapps-certification-persistence/src/IOHK/Certification/Persistence.hs index e306b9b1..2c57ac5a 100644 --- a/dapps-certification-persistence/src/IOHK/Certification/Persistence.hs +++ b/dapps-certification-persistence/src/IOHK/Certification/Persistence.hs @@ -20,6 +20,8 @@ import IOHK.Certification.Persistence.Structure as X , TransactionEntry(..) , SubscriptionDTO(..) , TierDTO(..) + , ProfileWallet(..) + , WalletAddressStatus(..) ) import Database.Selda as X ( fromId @@ -37,7 +39,9 @@ import IOHK.Certification.Persistence.Structure.Subscription as X , TierId ) import IOHK.Certification.Persistence.API as X - ( AdaUsdPrice + ( MinimalTransaction(..) + , MinimalTransactionEntry(..) + , AdaUsdPrice , upsertProfile , upsertTransaction , getProfile @@ -59,7 +63,6 @@ import IOHK.Certification.Persistence.API as X , markAsReadyForCertification , getAllCertifiedRunsForAddress , getRunsToCertify - , getAllAmountsForAddress , getProfileBalance , addInitialData , getProfileSubscriptions @@ -72,4 +75,8 @@ import IOHK.Certification.Persistence.API as X , getCurrentFeatures , getJWTSecret , insertJWTSecret + , getAllTransactions + , getProfileWallets + , getProfileWallet + , upsertProfileWallet ) diff --git a/dapps-certification-persistence/src/IOHK/Certification/Persistence/API.hs b/dapps-certification-persistence/src/IOHK/Certification/Persistence/API.hs index d1c9bb6e..9d10267d 100644 --- a/dapps-certification-persistence/src/IOHK/Certification/Persistence/API.hs +++ b/dapps-certification-persistence/src/IOHK/Certification/Persistence/API.hs @@ -19,9 +19,13 @@ import IOHK.Certification.Persistence.Structure.Run import IOHK.Certification.Persistence.Structure.Certification import IOHK.Certification.Persistence.Structure import Data.Time.Clock +import Data.Fixed import Data.Int +import Data.Bifunctor -import Data.Fixed +import Data.Functor + +import qualified Data.Map as Map getTransactionIdQ:: Text -> Query t (Col t (ID Transaction)) getTransactionIdQ externalAddress = do @@ -137,14 +141,20 @@ getProfileBalance address = do -- get all paid subscriptions paidSubscriptions <- getAllPaidSubscriptions pid -- get all the amounts coming from this address - amountsFromAddress <- sum <$> getAllAmountsForAddress address + walletIncomingCredits <- getProfileWallet pid <&> getWalletCredits -- sum all the costs of the certified runs let certifiedCosts = sum $ map certificationPrice certifiedRuns -- calculate the amount of credits available for subscriptions subscriptionCredits = sum $ map subscriptionPrice paidSubscriptions -- calculate the amount of credits available - creditsAvailable = amountsFromAddress - certifiedCosts - subscriptionCredits + creditsAvailable = walletIncomingCredits - fromIntegral certifiedCosts - fromIntegral subscriptionCredits pure $ Just creditsAvailable + where + getWalletCredits :: Maybe (Profile, Maybe ProfileWallet) -> Int64 + getWalletCredits Nothing = 0 + getWalletCredits (Just (_, Nothing)) = 0 + getWalletCredits (Just (_, Just ProfileWallet{..})) = profileWalletCredits + upsertProfile :: (MonadSelda m, MonadMask m) => Profile -> Maybe DApp -> m (Maybe (ID Profile)) upsertProfile profile@Profile{..} dappM = do @@ -203,6 +213,39 @@ getProfileDAppQ pid = do restrict (dapp ! #dappId .== literal pid) pure dapp +getProfileWalletQ :: ProfileId -> Query t (Row t Profile :*: Row t (Maybe ProfileWallet)) +getProfileWalletQ profileId = do + p <- select profiles + restrict (p ! #profileId .== literal profileId) + profileWallet <- leftJoin (\pw -> pw ! #profileWalletId .== p ! #profileId) (select profileWallets) + pure (p :*: profileWallet) + +getProfileWallet :: MonadSelda f => ProfileId -> f (Maybe (Profile , Maybe ProfileWallet)) +getProfileWallet profileId = fmap toTuple . listToMaybe <$> query (getProfileWalletQ profileId) + +toTuple :: a :*: b -> (a, b) +toTuple (p :*: pw) = (p,pw) + +getProfileWalletsQ :: Query t (Row t Profile :*: Row t (Maybe ProfileWallet)) +getProfileWalletsQ = do + p <- select profiles + profileWallet <- leftJoin (\pw -> pw ! #profileWalletId .== p ! #profileId) (select profileWallets) + pure (p :*: profileWallet) + +getProfileWallets :: MonadSelda m => m [(Profile, Maybe ProfileWallet)] +getProfileWallets = map toTuple <$> query getProfileWalletsQ + +upsertProfileWallet :: (MonadSelda m,MonadMask m) => ProfileWallet -> m () +upsertProfileWallet ProfileWallet{..} = do + void $ upsert profileWallets + (\pw -> pw ! #profileWalletId .== literal profileWalletId) + (`with` + [ #profileWalletAddress := text profileWalletAddress + , #profileWalletStatus := literal profileWalletStatus + , #profileWalletCredits := literal profileWalletCredits + ]) + [ProfileWallet{..}] + getProfile :: MonadSelda m => ID Profile -> m (Maybe ProfileDTO) getProfile pid = fmap (fmap toProfileDTO . listToMaybe ) $ query $ getProfileQ pid @@ -249,14 +292,6 @@ getRunOwnerQ runId = do restrict (p ! #runId .== literal runId ) pure (p ! #profileId) -getAllAmountsForAddress :: MonadSelda m => Text -> m [Int64] -getAllAmountsForAddress address = query $ do - input <- select transactionEntries - restrict (input ! #txEntryAddress .== literal address .&& input ! #txEntryInput .== literal True) - t <- innerJoin (\t -> (t ! #wtxId .== (input ! #txEntryTxId)) - .&& (t ! #wtxStatus .== literal InLedger)) (select transactions) - pure (t ! #wtxAmount) - getRunOwner :: MonadSelda m => UUID -> m (Maybe (ID Profile)) getRunOwner = fmap listToMaybe . query . getRunOwnerQ @@ -515,6 +550,51 @@ getAllTiers = do , tierDtoTier = Tier{..} } +type JustOutput = Bool + + +data MinimalTransaction = MinimalTransaction + { mtxTxId :: !(ID Transaction) + , mtxAmount :: !Int64 + , mtxMetadata :: !Text + } +data MinimalTransactionEntry = MinimalTransactionEntry + { mteId :: !(ID TransactionEntry) + , mteAddress :: !Text + , mteInput :: !Bool + } + +getAllTransactions :: MonadSelda m => JustOutput -> m [(MinimalTransaction,[MinimalTransactionEntry])] +getAllTransactions justOutput = do + tx <- query $ do + t <- select transactions + order (t ! #wtxTime) ascending + pure t + entries <- query $ do + e <- select transactionEntries + when justOutput $ + restrict (e ! #txEntryInput .== literal justOutput) + pure e + -- make a map of entries by txId + let entriesMap = Map.fromListWith (<>) $ map (\e -> (txEntryTxId e, [e])) entries + -- link the entries to the transactions + let txs = map (\t -> (t, Map.findWithDefault [] (wtxId t) entriesMap)) tx + -- convert to the minimal representation + pure $ map (bimap toMinimalTransaction (map toMinimalTransactionEntry)) txs + where + toMinimalTransactionEntry :: TransactionEntry -> MinimalTransactionEntry + toMinimalTransactionEntry TransactionEntry{..} = MinimalTransactionEntry + { mteId = txEntryId + , mteAddress = txEntryAddress + , mteInput = txEntryInput + } + toMinimalTransaction :: Transaction -> MinimalTransaction + toMinimalTransaction Transaction{..} = MinimalTransaction + { mtxTxId = wtxId + , mtxAmount = wtxAmount + , mtxMetadata = wtxMetadata + } + --TODO: replace this with a proper configuration withDb :: (MonadIO m, MonadMask m) => SeldaT SQLite m a -> m a withDb = withSQLite "certification.sqlite" diff --git a/dapps-certification-persistence/src/IOHK/Certification/Persistence/Structure.hs b/dapps-certification-persistence/src/IOHK/Certification/Persistence/Structure.hs index 8c50a696..e4167ba3 100644 --- a/dapps-certification-persistence/src/IOHK/Certification/Persistence/Structure.hs +++ b/dapps-certification-persistence/src/IOHK/Certification/Persistence/Structure.hs @@ -9,6 +9,7 @@ {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE RecordWildCards #-} {-# LANGUAGE DerivingVia #-} +{-# LANGUAGE LambdaCase #-} {-# OPTIONS_GHC -Wno-ambiguous-fields #-} {-# OPTIONS_GHC -Wno-orphans #-} @@ -18,10 +19,14 @@ module IOHK.Certification.Persistence.Structure where import Control.Lens hiding (index, (.=)) import Data.Aeson import Data.Proxy ---import Control.Exception ( throw) import Data.Swagger hiding (Contact) import Database.Selda import Data.Int +import Database.Selda.SqlType as Selda +import Control.Exception ( throw) +import GHC.OverloadedLabels +import Data.Char as Char +import Data.Int (Int64) import IOHK.Certification.Persistence.Structure.Profile import IOHK.Certification.Persistence.Structure.Subscription @@ -89,6 +94,57 @@ instance ToSchema TierDTO where & properties %~ (`mappend` [ ("features", featureSchema) ]) & required %~ (<> [ "features" ]) +data WalletAddressStatus = Reserved | Overlapping + deriving (Generic, Show, Eq) + +instance FromJSON WalletAddressStatus where + parseJSON = withText "WalletAddressStatus" $ \case + "reserved" -> pure Reserved + "overlapping" -> pure Overlapping + _ -> fail "WalletAddressStatus must be one of: reserved, overlapping" + +instance ToJSON WalletAddressStatus where + toJSON = \case + Overlapping -> "overlapping" + Reserved -> "reserved" + +instance ToSchema WalletAddressStatus where + declareNamedSchema _ = do + let values = [ "reserved", "overlapping" ] :: [Value] + return $ NamedSchema (Just "WalletAddressStatus") $ mempty + & type_ ?~ SwaggerString + & enum_ ?~ values + +instance SqlType WalletAddressStatus where + mkLit n = LCustom TInt64 (LInt64 (toInt64 n)) + where + toInt64 = \case + Overlapping -> 0 + Reserved -> 1 + sqlType _ = TInt64 + fromSql (SqlInt64 0) = Overlapping + fromSql (SqlInt64 1) = Reserved + fromSql v = throw $ userError $ "fromSql: expected SqlInt64, got " ++ show v + defaultValue = mkLit Overlapping + +data ProfileWallet = ProfileWallet + { profileWalletId :: ID Profile + , profileWalletAddress :: Text + , profileWalletStatus :: WalletAddressStatus + , profileWalletCredits :: Int64 + } deriving (Generic, Show, Eq) + +instance SqlRow ProfileWallet + +instance ToJSON ProfileWallet where + toJSON = genericToJSON defaultOptions { fieldLabelModifier = dropAndLowerFirst 14 } + +instance FromJSON ProfileWallet where + parseJSON = genericParseJSON defaultOptions { fieldLabelModifier = dropAndLowerFirst 14 } + +instance ToSchema ProfileWallet where + declareNamedSchema = genericDeclareNamedSchema defaultSchemaOptions { fieldLabelModifier = dropAndLowerFirst 14 } + -------------------------------------------------------------------------------- -- | Profile @@ -220,10 +276,18 @@ transactions = table "transaction" ] transactionEntries :: Table TransactionEntry -transactionEntries = table "entry" +transactionEntries = table "transaction_entry" [ #txEntryId :- autoPrimary , #txEntryTxId :- foreignKey transactions #wtxId ] + +profileWallets :: Table ProfileWallet +profileWallets = table "profile_wallet" + [ #profileWalletId :- primary + , #profileWalletId :- foreignKey profiles #profileId + ] + + dapps :: Table DApp dapps = table "dapp" [ #dappId :- unique @@ -235,6 +299,7 @@ createTables = do createTable certifications createTable onChainCertifications createTable profiles + createTable profileWallets createTable dapps createTable runs createTable transactions diff --git a/dapps-certification-persistence/src/IOHK/Certification/Persistence/example.json b/dapps-certification-persistence/src/IOHK/Certification/Persistence/example.json new file mode 100644 index 00000000..3d072b22 --- /dev/null +++ b/dapps-certification-persistence/src/IOHK/Certification/Persistence/example.json @@ -0,0 +1,21 @@ +{ + "0": { + "map": [ + { + "k": { + "string": "payer" + }, + "v": { + "list": [ + { + "string":"addr_test1qphgqts20fhx0yx7ug42xehcnryukchy5k7hpaksgxax2fzt5w2gu3" + }, + { + "string": "3s8wrw3c9tjs97dr5pulsvf39e56v7c9ar39asptcrtp" + } + ] + } + } + ] + } +} diff --git a/dapps-certification-signature-verification/src/IOHK/Certification/SignatureVerification.hs b/dapps-certification-signature-verification/src/IOHK/Certification/SignatureVerification.hs index 51662a0d..a9a36013 100644 --- a/dapps-certification-signature-verification/src/IOHK/Certification/SignatureVerification.hs +++ b/dapps-certification-signature-verification/src/IOHK/Certification/SignatureVerification.hs @@ -8,6 +8,7 @@ module IOHK.Certification.SignatureVerification , COSESign1 , encodeHex , decodeHex + , bech32AddressHash ) where import IOHK.Certification.SignatureVerification.CBOR as CBOR diff --git a/plutus-certification.cabal b/plutus-certification.cabal index fe01bcf0..cecc6019 100644 --- a/plutus-certification.cabal +++ b/plutus-certification.cabal @@ -72,6 +72,7 @@ library Plutus.Certification.WalletClient Plutus.Certification.Synchronizer Plutus.Certification.Metadata + Plutus.Certification.ProfileWallet other-modules: Paths_plutus_certification Plutus.Certification.Metadata.Types @@ -80,7 +81,7 @@ library Plutus.Certification.Web3StorageClient Plutus.Certification.Internal Plutus.Certification.WalletClient.Transaction - Plutus.Certification.TransactionBroadcaster + Plutus.Certification.CertificationBroadcaster Plutus.Certification.Server.Internal Plutus.Certification.Server.Instance Plutus.Certification.CoinGeckoClient diff --git a/server/Main.hs b/server/Main.hs index fcc4df2f..308961f3 100644 --- a/server/Main.hs +++ b/server/Main.hs @@ -60,16 +60,19 @@ import IOHK.Certification.Actions import Plutus.Certification.JWT import Data.Int import IOHK.Certification.Persistence (toId) +import Plutus.Certification.ProfileWallet import Paths_plutus_certification qualified as Package import IOHK.Certification.Persistence qualified as DB import Data.HashSet as HashSet +import Data.Word import qualified Data.ByteString.Lazy.Char8 as LBS import qualified Data.Text as Text import System.Environment (lookupEnv) import Crypto.Random - +oneAda :: Word64 +oneAda = 1000000 data Backend = Local @@ -85,6 +88,7 @@ data Args = Args , useWhitelist :: !Bool , github :: !GitHubArgs , bypassSubscriptionValidation :: !Bool + , minAmountForAddressAssessment :: !Word64 } data GitHubArgs = GitHubArgs @@ -152,6 +156,13 @@ argsParser = Args ( long "unsafe-bypass-subscription-validation" <> help "Bypass subscription validation" ) + <*> option auto + ( long "min-amount-for-address-assessment" + <> metavar "MIN_AMOUNT" + <> help "the minimum amount of Lovelace required to perform an address assessment" + <> showDefault + <> Opts.value oneAda + ) data AuthMode = JWTAuth JWTArgs | PlainAddressAuth @@ -182,7 +193,6 @@ jwtModeParser = <> help "use the jwt token generated within the db" ) - jwtArgsParser :: Parser AuthMode jwtArgsParser = JWTAuth <$> (JWTArgs <$> jwtModeParser @@ -268,7 +278,6 @@ data RootEventSelector f where InjectLocal :: forall f . !(LocalSelector f) -> RootEventSelector f InjectSynchronizer :: forall f . !(SynchronizerSelector f) -> RootEventSelector f - renderRoot :: RenderSelectorJSON RootEventSelector renderRoot Initializing = ( "initializing" @@ -422,6 +431,7 @@ main = do -- get the whitelisted addresses from $WLIST env var -- if useWhitelist is set to false the whitelist is ignored whitelist <- if not args.useWhitelist then pure Nothing else Just <$> whitelisted + addressRotation <- liftIO $ newMVar emptyAddressRotation _ <- initDb jwtConfig <- getJwtArgs eb (args.auth) adaPriceRef <- startSynchronizer eb args @@ -430,7 +440,7 @@ main = do cors (const $ Just corsPolicy) . serveWithContext (Proxy @APIWithSwagger) (genAuthServerContext whitelist jwtConfig) . (\r -> swaggerSchemaUIServer (documentation args.auth) - :<|> server (serverArgs args caps r eb whitelist adaPriceRef jwtConfig)) + :<|> server (serverArgs args caps r eb whitelist adaPriceRef jwtConfig addressRotation)) exitFailure where getJwtArgs eb authMode = withEvent eb OnAuthMode \ev -> @@ -471,19 +481,20 @@ main = do startSynchronizer eb args = do ref <- newIORef Nothing - _ <- forkIO $ startTransactionsMonitor (narrowEventBackend InjectSynchronizer eb) (args.wallet) ref 10 + _ <- forkIO $ startTransactionsMonitor + (narrowEventBackend InjectSynchronizer eb) + (args.wallet) ref 10 (args.minAmountForAddressAssessment) pure ref - serverArgs args caps r eb whitelist ref jwtConfig = ServerArgs - { serverCaps = caps - , serverWalletArgs = args.wallet - , githubToken = args.github.accessToken - , serverJWTConfig = jwtConfig + serverArgs args serverCaps r eb whitelist ref serverJWTConfig serverAddressRotation = ServerArgs + { serverWalletArgs = args.wallet + , serverGithubToken = args.github.accessToken , serverEventBackend = be r eb , serverSigningTimeout = args.signatureTimeout - , serverWhitelist = whitelist :: Maybe Whitelist - , validateSubscriptions = not args.bypassSubscriptionValidation + , serverWhitelist = whitelist + , serverValidateSubscriptions = not args.bypassSubscriptionValidation , serverGitHubCredentials = args.github.credentials - , adaUsdPrice = liftIO $ readIORef ref + , serverAdaUsdPrice = liftIO $ readIORef ref + , .. } documentation PlainAddressAuth = swaggerJson documentation (JWTAuth _) = swaggerJsonWithLogin diff --git a/src/Plutus/Certification/API/Routes.hs b/src/Plutus/Certification/API/Routes.hs index 06c557bb..c7fd5fe8 100644 --- a/src/Plutus/Certification/API/Routes.hs +++ b/src/Plutus/Certification/API/Routes.hs @@ -1,5 +1,5 @@ {-# LANGUAGE DataKinds #-} -{-# LANGUAGE OverloadedLists #-} +{-# LANGUAGE OverloadedLists #-} {-# LANGUAGE TypeOperators #-} {-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE DerivingStrategies #-} @@ -35,7 +35,6 @@ import Data.Swagger import IOHK.Certification.Interface import Data.Time import Data.Proxy -import Data.Int import Text.Read hiding (String) import Plutus.Certification.WalletClient import Control.Lens hiding ((.=)) @@ -48,6 +47,7 @@ import IOHK.Certification.SignatureVerification import Data.Char (isAlphaNum) import Text.Regex import Plutus.Certification.Metadata as Metadata +import Data.Int import qualified Data.Swagger.Lens as SL import qualified IOHK.Certification.Persistence as DB @@ -139,6 +139,13 @@ type WalletAddressRoute = "wallet-address" :> Description "Get the wallet address the backend operates with" :> Get '[JSON] WalletAddress +type GetProfileWalletAddressRoute (auth :: Symbol) = "profile" + :> Description "Get the wallet address of the profile" + :> "current" + :> "wallet-address" + :> AuthProtect auth + :> Get '[JSON] (Maybe (DB.WalletAddressStatus,WalletAddress)) + type GitHubRoute = "repo" :> Description "Get the github repo information" :> Capture "owner" Text @@ -296,6 +303,7 @@ data NamedAPI (auth :: Symbol) mode = NamedAPI , updateCurrentProfile :: mode :- UpdateCurrentProfileRoute auth , createCertification :: mode :- CreateCertificationRoute auth , getCertification :: mode :- GetCertificateRoute + , getProfileWalletAddress :: mode :- GetProfileWalletAddressRoute auth , walletAddress :: mode :- WalletAddressRoute , getProfileBalance :: mode :- GetBalanceRoute auth , getRunDetails :: mode :- GetRunDetailsRoute diff --git a/src/Plutus/Certification/API/Swagger.hs b/src/Plutus/Certification/API/Swagger.hs index ea91bae9..46093ec0 100644 --- a/src/Plutus/Certification/API/Swagger.hs +++ b/src/Plutus/Certification/API/Swagger.hs @@ -51,6 +51,7 @@ type UnnamedApi (auth :: Symbol) :<|> GetProfileActiveFeaturesRoute auth :<|> GetAdaUsdPriceRoute :<|> CreateAuditorReport auth + :<|> GetProfileWalletAddressRoute auth type UnnamedApiWithLogin (auth :: Symbol) = UnnamedApi auth diff --git a/src/Plutus/Certification/TransactionBroadcaster.hs b/src/Plutus/Certification/CertificationBroadcaster.hs similarity index 97% rename from src/Plutus/Certification/TransactionBroadcaster.hs rename to src/Plutus/Certification/CertificationBroadcaster.hs index cdd9c490..134b3f16 100644 --- a/src/Plutus/Certification/TransactionBroadcaster.hs +++ b/src/Plutus/Certification/CertificationBroadcaster.hs @@ -11,7 +11,7 @@ {-# LANGUAGE DataKinds #-} {-# LANGUAGE TypeFamilies #-} -module Plutus.Certification.TransactionBroadcaster +module Plutus.Certification.CertificationBroadcaster ( createL1Certification , renderTxBroadcasterSelector , TxBroadcasterSelector(..) @@ -74,8 +74,8 @@ createL1Certification eb wargs profileId rid@RunID{..} = withEvent eb CreateCert let certificate = Wallet.CertificationMetadata uuid (DB.IpfsCid ipfsCid) dappName websiteUrl (profile.twitter) uri dappVersion - -- broadcast the l1 certification - tx@Wallet.TxResponse{..} <- Wallet.broadcastTransaction wargs 1304 certificate + -- broadcast the certification + tx@Wallet.TxResponse{..} <- Wallet.broadcastTransaction wargs Nothing 1304 certificate >>= eitherToError show addField ev (CreateCertificationTxResponse tx) diff --git a/src/Plutus/Certification/ProfileWallet.hs b/src/Plutus/Certification/ProfileWallet.hs new file mode 100644 index 00000000..2e06ffa7 --- /dev/null +++ b/src/Plutus/Certification/ProfileWallet.hs @@ -0,0 +1,556 @@ +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE TupleSections #-} +{-# LANGUAGE GADTs #-} +{-# LANGUAGE BlockArguments #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE ScopedTypeVariables #-} +{-# LANGUAGE RecordWildCards #-} +{-# LANGUAGE QuasiQuotes #-} +{-# LANGUAGE OverloadedRecordDot #-} +module Plutus.Certification.ProfileWallet + ( resyncWallets + , renderProfileWalletSyncSelector + , ProfileWalletSyncSelector + , PrevAssignments + , AddressReservation(..) + , getTemporarilyWalletAddress + , AddressRotation(..) + , emptyAddressRotation + , WalletAddress(..) + , ProfileAddress(..) + ) where + +import Prelude as P + +import Servant.Client.Core +import Data.Aeson +import Data.Aeson.Types +import Data.List +import Control.Monad as M +import Control.Lens.Internal.CTypes (Word64) +import Data.Text (Text) +import Conduit (MonadIO) +import Control.Arrow +import Data.Functor +import Control.Monad.Catch (MonadMask) +import Data.Map (Map) +import Plutus.Certification.Internal +import Data.Aeson.QQ +import Plutus.Certification.WalletClient (WalletArgs) +import Data.Either +import Observe.Event.Render.JSON +import Observe.Event +import Data.Function + +import qualified Plutus.Certification.WalletClient as Wallet +import qualified IOHK.Certification.Persistence as DB +import qualified IOHK.Certification.SignatureVerification as Sig +import qualified Data.Vector as V +import qualified Data.Text.Encoding as T +import qualified Data.Map as Map + +-------------------------------------------------------------------------------- +-- | Basic types for the profile wallet + +newtype WalletAddress = WalletAddress { unWalletAddress :: Text } + deriving (Eq,Show,Ord) + +newtype ProfileAddress = ProfileAddress { unProfileAddress :: Text } + deriving (Ord,Eq,Show) + +data AddressReservation = Reserved | Overlapping + +data ProfileWallet = ProfileWallet + { pwAddressReservation :: (AddressReservation, WalletAddress) + , pwProfileAddress :: ProfileAddress + , pwBalance :: Word64 + } + +data Transaction = SimplePayment WalletAddress Word64 + | DesignatedPayment ProfileAddress WalletAddress Word64 + | WalletAddressAssignment ProfileAddress WalletAddress + +type ProfileWallets = [ProfileWallet] +type ExtraMoney = Word64 + +newtype PayerMetadata = PayerMetadata { unPayerMetadata :: Text } + deriving (Eq,Show) +-- it will look like this: +-- +-- { +-- "0": { +-- "map": [ +-- { +-- "k": { +-- "string": "payer" +-- }, +-- "v": { +-- "list": [ +-- { +-- "string":"addr_test1qphgqts20fhx0yx7ug42xehcnryukchy5k7hpaksgxax2fzt5w2gu3" +-- }, +-- { +-- "string": "3s8wrw3c9tjs97dr5pulsvf39e56v7c9ar39asptcrtp" +-- } +-- ] +-- } +-- } +-- ] +-- } +-- } + +instance FromJSON PayerMetadata where + parseJSON = fmap PayerMetadata . parseMetaData "PayerMetadata" "payer" + +-- \"list\": [ { \"string\":\"addr_test1qphgqts20fhx0yx7ug42xehcnryukchy5k7hpaksgxax2fzt5w2gu3\" }, { \"string\": \"3s8wrw3c9tjs97dr5pulsvf39e56v7c9ar39asptcrtp\" } ] + +{- +>>> t = "{ \"0\": { \"map\": [ { \"k\": { \"string\": \"payer\" }, \"v\": {\"list\": [ { \"string\":\"addr_test1qphgqts20fhx0yx7ug42xehcnryukchy5k7hpaksgxax2fzt5w2gu3\" }, { \"string\": \"3s8wrw3c9tjs97dr5pulsvf39e56v7c9ar39asptcrtp\" } ]} } ] } }" +>>> eitherDecode t :: Either String PayerMetadata +Right (PayerMetadata {unPayerMetadata = "addr_test1qphgqts20fhx0yx7ug42xehcnryukchy5k7hpaksgxax2fzt5w2gu33s8wrw3c9tjs97dr5pulsvf39e56v7c9ar39asptcrtp"}) +-} + +newtype AddressAssignmentMetadata = AddressAssignmentMetadata { unAddressAssignmentMetadata :: Text } + deriving (Eq,Show) + +instance FromJSON AddressAssignmentMetadata where + parseJSON = fmap AddressAssignmentMetadata . parseMetaData "AddressAssignment" "assignment" + +{- +>>> t = "{ \"0\": { \"map\": [ { \"k\": { \"string\": \"assignment\" }, \"v\": {\"list\": [ { \"string\":\"addr_test1qphgqts20fhx0yx7ug42xehcnryukchy5k7hpaksgxax2fzt5w2gu3\" }, { \"string\": \"3s8wrw3c9tjs97dr5pulsvf39e56v7c9ar39asptcrtp\" } ]} } ] } }" +>>> eitherDecode t :: Either String AddressAssignmentMetadata +Right (AddressAssignmentMetadata {unAddressAssignmentMetadata = "addr_test1qphgqts20fhx0yx7ug42xehcnryukchy5k7hpaksgxax2fzt5w2gu33s8wrw3c9tjs97dr5pulsvf39e56v7c9ar39asptcrtp"}) +-} + +parseMetaData :: String -> Text -> Value -> Parser Text +parseMetaData label keyName = withObject label $ \o -> do + (Array ar) <- o .: "0" >>= (.: "map") + -- vector to list + -- and map to (key, value) + xs <- M.mapM extractKeys (V.toList ar) + case xs of + [(key,payerAddress)] | keyName == key -> return payerAddress + _ -> fail $ "Failed reading: map is not [(<"++ show keyName P.++">,
)]" + where + extractKeys (Object o') = do + (String key) <- (o' .: "k") >>= (.: "string") + (Array ar) <- (o' .: "v") >>= (.: "list") + -- map vector of Value to vector of String + value <- mconcat . V.toList <$> V.mapM extractString ar + return (key, value) + extractKeys _ = fail "Failed reading: map is not an object" + extractString = withObject "extractString" $ \o -> do + (String s) <- o .: "string" + return s + +-------------------------------------------------------------------------------- +-- | Events + +data ResyncWalletsArgs = TransactionMappingErrors !Int + | ExtraMoney !ExtraMoney + | TransactionMappingCounts !(Int,Int,Int) + +data AssignAddressArgs + = AssignAddressInputs !(ProfileAddress, WalletAddress, OverlappingAddress) + | AssignAddressError !String + | AssignAddressTxResp !Wallet.TxResponse + +data ProfileWalletSyncSelector f where + ResyncWallets :: ProfileWalletSyncSelector ResyncWalletsArgs + UpdateDbWallet :: ProfileWalletSyncSelector DB.ProfileWallet + AssignAddress :: ProfileWalletSyncSelector AssignAddressArgs + UnassignAddressListError :: ProfileWalletSyncSelector ClientError + +renderProfileWalletSyncSelector :: RenderSelectorJSON ProfileWalletSyncSelector +renderProfileWalletSyncSelector UpdateDbWallet = + ("update-db-wallet", renderDbProfileWallet) +renderProfileWalletSyncSelector ResyncWallets = + ("resync-wallets", \case + TransactionMappingErrors errors -> ("transaction-mapping-errors", toJSON errors) + ExtraMoney extraMoney -> ("extra-money", toJSON extraMoney) + TransactionMappingCounts (simple,designated,assignments) -> + ("transaction-mapping-count", toJSON [aesonQQ| + { "simplePayments" : #{simple} + , "designatedPayments" : #{designated} + , "addressAssignments" : #{assignments} + } |] + ) + ) +renderProfileWalletSyncSelector AssignAddress = ("assign-address", \case + AssignAddressInputs (ProfileAddress{..}, WalletAddress{..}, overlappingAddress) -> + let overlappingAddress' = overlappingAddress.unWalletAddress + in ("assign-address-inputs", toJSON [aesonQQ| + { "profileAddress" : #{unProfileAddress} + , "destinationAddress" : #{unWalletAddress} + , "overlappingAddress" : #{overlappingAddress'} + } |] ) + AssignAddressError err -> ("assign-address-error", toJSON err) + AssignAddressTxResp txResp -> ("assign-address-tx-id", toJSON txResp) + ) +renderProfileWalletSyncSelector UnassignAddressListError + = ("unassign-address-list-error", \err -> ("error", toJSON (show err))) + +renderDbProfileWallet :: RenderFieldJSON DB.ProfileWallet +renderDbProfileWallet wallet = ("profile-wallet", toJSON wallet) + +type WalletBackend m r = EventBackend m r ProfileWalletSyncSelector + +-------------------------------------------------------------------------------- +-- | MAIN WALLET SYNC FUNCTIONS + +resyncWallets :: (MonadIO m,MonadMask m) + => WalletBackend m r + -> WalletArgs + -> Word64 + -> PrevAssignments + -> m PrevAssignments +resyncWallets eb wargs minAmount prevAssignments = withEvent eb ResyncWallets \ev -> do + -- fetch the db transactions and create wallets + (errors,trans) <- DB.withDb ( DB.getAllTransactions False ) + <&> (lefts &&& rights) . fmap fromDbTransaction' + addField ev $ TransactionMappingErrors (length errors) + addField ev $ TransactionMappingCounts (countTransactions trans) + + let (profileWallets,extraMoney) = createProfileWallets trans + addField ev $ ExtraMoney extraMoney + + -- sync the wallets with the db + syncDbProfileWallets eb profileWallets + + -- assign addresses for overlapping wallets + reassignOverlappingAddresses eb wargs profileWallets prevAssignments + + where + fromDbTransaction' = uncurry (fromDbTransaction isOurAddress minAmount) + mainHash = hash wargs.walletAddress + hash = Sig.bech32AddressHash . Sig.Bech32Address + isOurAddress = (== mainHash) . hash + countTransactions = foldl' (\(a,b,c) -> \case + SimplePayment{} -> (a+1,b,c) + DesignatedPayment{} -> (a,b+1,c) + WalletAddressAssignment{} -> (a,b,c+1) + ) (0,0,0) +-------------------------------------------------------------------------------- +-- | ADDRESS ASSIGNMENT DISSEMINATION + +data Assignment = Assignment + { assgnOverlappingAddress :: WalletAddress + , assgnDestinationAddress :: WalletAddress + , assgnProfileAddress :: ProfileAddress + } deriving (Eq,Show) +type PrevAssignments = [Assignment] + +type OverlappingAddress = WalletAddress +type DestinationAddress = WalletAddress + + +-- TODO: before calling this verify profileId is not a wallet address +assignAddress :: (MonadIO m,MonadMask m) + => WalletBackend m r + -> WalletArgs + -> PrevAssignments + -> ProfileAddress + -> OverlappingAddress + -> DestinationAddress + -> m (Either String PrevAssignments) +assignAddress eb wargs prevAssignments profileAddress overlappingAddress destinationAddress = + withEvent eb AssignAddress \ev -> do + + addField ev $ AssignAddressInputs (profileAddress, destinationAddress, overlappingAddress ) + + if wasAssignedBefore + then do + let err = "Address already assigned" + addField ev $ AssignAddressError err + pure $ Left err + else reserveAddress ev + + where + reserveAddress ev = do + let address = split64 (destinationAddress.unWalletAddress) + let metadata = [aesonQQ| { "assignment" : #{address} } |] + + resp <- Wallet.broadcastTransaction wargs (Just profileAddress.unProfileAddress) 0 metadata + case resp of + Left err -> do + addField ev $ AssignAddressError (show err) + return $ Left $ show err + Right resp' -> do + addField ev $ AssignAddressTxResp resp' + let assignment = Assignment overlappingAddress destinationAddress profileAddress + return $ Right (assignment:prevAssignments) + + wasAssignedBefore :: Bool + wasAssignedBefore = + -- search to see if the address was already assigned + any (\Assignment{..} -> assgnOverlappingAddress == overlappingAddress + && assgnProfileAddress == profileAddress + ) prevAssignments + +reassignOverlappingAddresses :: forall m r. (MonadIO m,MonadMask m) + => WalletBackend m r + -> WalletArgs + -> ProfileWallets + -> PrevAssignments + -> m PrevAssignments +reassignOverlappingAddresses eb wargs profileWallets prevAssignments = do + -- filter out non overlapping addresses or addresses that were already assigned + let overlappingAddress = filter isOverlappingAddress profileWallets + -- get unused addresses which were not assigned before + unusedAddresses <- (fmap . filter) (not . wasAssignedBefore) <$> + Wallet.getWalletAddresses wargs (Just Wallet.Unused) + case unusedAddresses of + Left err -> withEvent eb UnassignAddressListError \ev -> do + addField ev err + return prevAssignments + Right unusedAddresses' -> + -- zip overlapping addresses with unused addresses + let assignments = zip overlappingAddress unusedAddresses' + in foldM assignAddress' prevAssignments assignments + where + assignAddress' :: (MonadIO m) + => PrevAssignments + -> (ProfileWallet, Wallet.WalletAddressInfo) + -> m PrevAssignments + assignAddress' prevAssignments' (ProfileWallet{..},addressInfo) = do + prevAddressE <- assignAddress eb wargs prevAssignments' pwProfileAddress + (snd pwAddressReservation) (WalletAddress addressInfo.addressId) + case prevAddressE of + Left _ -> return prevAssignments + Right prevAddress -> return prevAddress + isOverlappingAddress :: ProfileWallet -> Bool + isOverlappingAddress (ProfileWallet (Overlapping,address) profileAddress _) = + let isAlreadyAssigned = any + (\Assignment{..} -> assgnOverlappingAddress == address + && assgnProfileAddress == profileAddress + ) prevAssignments + in not isAlreadyAssigned + isOverlappingAddress _ = False + wasAssignedBefore :: Wallet.WalletAddressInfo -> Bool + wasAssignedBefore info = any (\Assignment{..} -> + assgnDestinationAddress == WalletAddress info.addressId) prevAssignments + +-------------------------------------------------------------------------------- +-- DB RELATED FUNCTIONS + +-- | Transform a transaction coming from the database into a `Transaction` +fromDbTransaction :: (Text -> Bool) + -> Word64 + -> DB.MinimalTransaction + -> [DB.MinimalTransactionEntry] + -> Either String Transaction +fromDbTransaction isOurAddress minAmount DB.MinimalTransaction{..} entries = + case (paymentMetadata,addressAssignmentMetadata,firstOutWalletAddress) of + -- address assignment + (_,Right (AddressAssignmentMetadata address),Just walletAddress) + | mtxAmount < 0 && not (isOurAddress address) + -> Right $ WalletAddressAssignment (ProfileAddress address) walletAddress + | isOurAddress address + -> Left "Address assignment shouldn't belong to our wallet" + | otherwise + -> Left "Address assignment transaction must be a withdrawal" + + (_,Right (AddressAssignmentMetadata _),Nothing) -> + Left "InternalError: no wallet address found for address assignment" + + (Right (PayerMetadata address),_,Just walletAddress) + | mtxAmount >= fromIntegral minAmount && not (isOurAddress address) + -> Right $ DesignatedPayment (ProfileAddress address) + walletAddress (fromIntegral mtxAmount) + | isOurAddress address + -> Left "Payer address should not belong to our wallet" + + (_,_,Just walletAddress) + | mtxAmount > 0 -> Right $ SimplePayment walletAddress (fromIntegral mtxAmount) + | mtxAmount < 0 -> Left "Payment transaction is an outgoing transaction without address assignment" + _ -> Left "Transaction is not a standard payment" + where + firstOutWalletAddress :: Maybe WalletAddress + firstOutWalletAddress = + -- from the list of entries, find the first entry that is an output + -- and that is in our wallet + let filtered = filter condition entries + condition DB.MinimalTransactionEntry{..} = + not mteInput && isOurAddress mteAddress + in case filtered of + [] -> Nothing + (DB.MinimalTransactionEntry{..}:_) -> Just (WalletAddress mteAddress) + + paymentMetadata :: Either String PayerMetadata + paymentMetadata = eitherDecodeStrict' (T.encodeUtf8 mtxMetadata) + + addressAssignmentMetadata :: Either String AddressAssignmentMetadata + addressAssignmentMetadata = eitherDecodeStrict' (T.encodeUtf8 mtxMetadata) + +-- | Get the list of all the wallets in the database +-- and update the database with the new list of wallets +syncDbProfileWallets :: forall m r. (MonadIO m, MonadMask m) + => WalletBackend m r + -> ProfileWallets + -> m () +syncDbProfileWallets eb wallets = do + DB.withDb DB.getProfileWallets >>= mapM_ updateProfileWallet + where + pwMap :: Map ProfileAddress ProfileWallet + pwMap = Map.fromList (map (\wallet@(ProfileWallet _ address _) -> (address,wallet)) wallets) + + updateProfileWallet :: (MonadIO m,MonadMask m) => (DB.Profile, Maybe DB.ProfileWallet) -> m () + updateProfileWallet (DB.Profile{..},dbWalletM) = + case (Map.lookup (ProfileAddress ownerAddress) pwMap,dbWalletM) of + -- if there is no wallet in the db, create it + (Just wallet,Nothing) -> upsertWallet (profileWalletToDBProfileWallet profileId wallet) + -- if there is a wallet in the db + -- and it is different from the one in the map update it + (Just wallet,Just dbProfileWallet) + | dbProfileWallet' <- profileWalletToDBProfileWallet profileId wallet + , dbProfileWallet /= dbProfileWallet' -> upsertWallet dbProfileWallet' + -- do nothing if there is no wallet in the map or the wallet is the same + _ -> pure () + + upsertWallet wallet = withEvent eb UpdateDbWallet \ ev -> do + DB.withDb $ DB.upsertProfileWallet wallet + addField ev wallet + + profileWalletToDBProfileWallet :: DB.ProfileId -> ProfileWallet -> DB.ProfileWallet + profileWalletToDBProfileWallet profileId ProfileWallet{..} = + DB.ProfileWallet + { profileWalletId = profileId + , profileWalletAddress = unWalletAddress $ snd pwAddressReservation + , profileWalletCredits = fromIntegral pwBalance + , profileWalletStatus = case fst pwAddressReservation of + Reserved -> DB.Reserved + Overlapping -> DB.Overlapping + } +-------------------------------------------------------------------------------- +-- | ADDRESS ROTATION + +data AddressRotation = AddressRotation + { byAddress :: Map WalletAddress [DB.ProfileId] + , byProfile :: Map DB.ProfileId WalletAddress + } deriving (Show,Eq) + +emptyAddressRotation :: AddressRotation +emptyAddressRotation = AddressRotation + { byAddress = Map.empty + , byProfile = Map.empty + } + +getTemporarilyWalletAddress :: [WalletAddress] + -> DB.ProfileId + -> AddressRotation + -> (Maybe WalletAddress,AddressRotation) +getTemporarilyWalletAddress addresses profileId rotation = + case (profileAddress,nextAddress) of + (Just address,_) -> (Just address,rotation) + (Nothing,Just address) -> (Just address,AddressRotation + { byAddress = Map.insertWith (++) address [profileId] (byAddress rotation) + , byProfile = Map.insert profileId address (byProfile rotation) + }) + (Nothing,Nothing) -> (Nothing,rotation) + + where + nextAddress :: Maybe WalletAddress + nextAddress = case sortedAddress of + [] -> Nothing + ((address,_):_) -> Just address + + profileAddress :: Maybe WalletAddress + profileAddress = Map.lookup profileId (byProfile rotation) + + sortedAddress :: [(WalletAddress,Int)] + sortedAddress = sortBy (compare `on` snd) $ + map (\address -> (address,Map.findWithDefault 0 address byAddressWithWeight)) addresses + + byAddressWithWeight :: Map WalletAddress Int + byAddressWithWeight = Map.fromListWith (+) (map (second length) (Map.toList (byAddress rotation))) + +-------------------------------------------------------------------------------- +-- | ProfileWallets related functions + +createProfileWallets :: [Transaction] -> (ProfileWallets,ExtraMoney) +createProfileWallets = P.foldl updateWallets ([],0) + where + updateWallets :: (ProfileWallets,ExtraMoney) -> Transaction -> (ProfileWallets,ExtraMoney) + updateWallets (wallets,extraMoney) transaction = + let (updatedWallets,extraMoney') = updateProfileWallets wallets transaction + in (updatedWallets,extraMoney + extraMoney') + +updateProfileWallets :: ProfileWallets -> Transaction -> (ProfileWallets,ExtraMoney) +updateProfileWallets wallets (SimplePayment receiverAddress amount) = + -- check if there is a reserved wallet for the receiverAddress + -- and if so, update the balance of the wallet + -- otherwise, return the wallets and the amount as extra money + case updateBalanceByWalletAddress wallets receiverAddress amount of + (updatedWallets,True) -> (updatedWallets,0) + (updatedWallets,False) -> (updatedWallets,amount) + +updateProfileWallets wallets (DesignatedPayment profileAddress receiverAddress amount) = + -- we can't have any extra money, because we know whose wallet we need to update + -- also here we reserve the receiverAddress if it is not already reserved + -- otherwise we mark it as overlapping + (searchAndUpdate False wallets,0) + where + -- when the profileAddress is already reserved, we need to: + searchAndUpdate addressReserved (wallet@(ProfileWallet (Reserved,address) profileAddress' balance):wallets') + -- check if the profileAddress is the same and if so, we need to update the balance + -- NOTE: we don't care about `receiverAddress` specified in the transaction because + -- we already have a reserved address + | profileAddress' == profileAddress = (wallet { pwBalance = balance + amount }):wallets' + -- otherwise we continue searching + -- and mark `receiverAddress` as reserved if it the same as the `address` + | otherwise = wallet:searchAndUpdate (addressReserved || address == receiverAddress) wallets' + + -- when the profileAddress is not reserved, but overlapping + searchAndUpdate addressReserved (wallet@(ProfileWallet (Overlapping,_) profileAddress' balance):wallets') + -- we need to check if the profileAddress is the same and if so, update the balance + | profileAddress' == profileAddress = (wallet + { pwBalance = balance + amount + -- but also we need to update the address reservation + , pwAddressReservation = (boolToReservation addressReserved, receiverAddress) + }):wallets' + -- otherwise we continue searching + | otherwise = wallet:searchAndUpdate addressReserved wallets' + + -- if we reach the empty list, we need to create a new profile wallet + searchAndUpdate addressReserved [] = + [ProfileWallet (boolToReservation addressReserved, receiverAddress) profileAddress amount] + +updateProfileWallets wallets (WalletAddressAssignment profileAddress receiverAddress) = + -- we can't have any extra money, because we just reserve the address + (reserveAddress wallets,0) + where + -- when the profileAddress is already reserved, we need to: + reserveAddress (wallet@(ProfileWallet (Reserved,address) profileAddress' _):wallets') + -- change the address if there is forced address assignation + -- for the same profileAddress + | profileAddress' == profileAddress = (wallet + { pwAddressReservation = (Reserved, receiverAddress) }):wallets' + -- or, if there is different profile with the pointing to the same receiverAddress + -- we ignore the assignment because the address is already reserved + | address == receiverAddress = wallet:wallets' + -- otherwise we continue searching + | otherwise = wallet:reserveAddress wallets' + + -- when the profileAddress is not reserved, but overlapping + reserveAddress (wallet@(ProfileWallet (Overlapping,_) profileAddress' _):wallets') + -- we need to check if the profileAddress is the same and if so, + -- update the address reservation + | profileAddress' == profileAddress = (wallet + { pwAddressReservation = (Reserved, receiverAddress) }):wallets' + -- otherwise we continue searching + | otherwise = wallet:reserveAddress wallets' + -- if we reach the empty list, we need to create a new profile wallet + -- with the reserved address and the balance of 0 + reserveAddress [] = [ProfileWallet (Reserved, receiverAddress) profileAddress 0] + +boolToReservation :: Bool -> AddressReservation +boolToReservation True = Overlapping +boolToReservation False = Reserved + +updateBalanceByWalletAddress :: [ProfileWallet] -> WalletAddress -> Word64 -> ([ProfileWallet],Bool) +updateBalanceByWalletAddress (wallet@(ProfileWallet (Reserved, walletAddress) _ balance) : rest) walletAddress' amount + | walletAddress == walletAddress' = (wallet { pwBalance = balance + amount } : rest,True) +updateBalanceByWalletAddress (wallet:rest) walletAddress' amount = + let (updatedWallets,found) = updateBalanceByWalletAddress rest walletAddress' amount + in (wallet : updatedWallets,found) +updateBalanceByWalletAddress [] _ _ = ([],False) diff --git a/src/Plutus/Certification/Server/Instance.hs b/src/Plutus/Certification/Server/Instance.hs index e470c66f..10b2bbdc 100644 --- a/src/Plutus/Certification/Server/Instance.hs +++ b/src/Plutus/Certification/Server/Instance.hs @@ -1,4 +1,5 @@ {-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE TupleSections #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE GADTs #-} @@ -34,7 +35,7 @@ import Data.Text as Text hiding (elem,replicate, last,words,replicate) import Data.Text.Encoding import Plutus.Certification.WalletClient (WalletArgs(walletCertificationPrice)) import IOHK.Certification.Interface hiding (Status) -import Plutus.Certification.Server.Internal +import Plutus.Certification.Server.Internal as I import Servant.Server.Experimental.Auth (AuthServerData) import Data.Time (addUTCTime) @@ -45,6 +46,8 @@ import Text.Read (readMaybe) import Data.HashSet as HashSet import Data.List as List import IOHK.Certification.Persistence (FeatureType(..)) +import Plutus.Certification.ProfileWallet as PW +import Data.Functor import qualified Data.ByteString.Lazy.Char8 as LSB import qualified Paths_plutus_certification as Package @@ -52,6 +55,7 @@ import qualified Plutus.Certification.WalletClient as Wallet import qualified IOHK.Certification.Persistence as DB import qualified Plutus.Certification.Web3StorageClient as IPFS import Plutus.Certification.Metadata +import Control.Concurrent (MVar, takeMVar, putMVar) hoistServerCaps :: (Monad m) => (forall x . m x -> n x) -> ServerCaps m r -> ServerCaps n r hoistServerCaps nt (ServerCaps {..}) = ServerCaps @@ -72,14 +76,15 @@ data GitHubCredentials = GitHubCredentials data ServerArgs m r = ServerArgs { serverCaps :: !(ServerCaps m r) , serverWalletArgs :: !Wallet.WalletArgs - , githubToken :: !(Maybe GitHubAccessToken) + , serverGithubToken :: !(Maybe GitHubAccessToken) , serverJWTConfig :: !(Maybe JWTConfig) , serverEventBackend :: !(EventBackend m r ServerEventSelector) , serverSigningTimeout :: !Seconds , serverWhitelist :: !(Maybe Whitelist) , serverGitHubCredentials :: !(Maybe GitHubCredentials) - , validateSubscriptions :: Bool - , adaUsdPrice :: m (Maybe DB.AdaUsdPrice) + , serverValidateSubscriptions :: Bool + , serverAdaUsdPrice :: m (Maybe DB.AdaUsdPrice) + , serverAddressRotation :: MVar AddressRotation } type Whitelist = HashSet Text @@ -107,12 +112,12 @@ server :: ( MonadMask m server ServerArgs{..} = NamedAPI { version = withEvent eb Version . const . pure $ VersionV1 Package.version , versionHead = withEvent eb Version . const $ pure NoContent - , walletAddress = withEvent eb WalletAddress . const $ pure serverWalletArgs.walletAddress + , walletAddress = withEvent eb I.WalletAddress . const $ pure serverWalletArgs.walletAddress , createRun = \(profileId,_) commitOrBranch -> withEvent eb CreateRun \ev -> do -- ensure the profile has an active feature for L1Run validateFeature L1Run profileId (fref,profileAccessToken) <- getFlakeRefAndAccessToken profileId commitOrBranch - let githubToken' = profileAccessToken <|> githubToken + let githubToken' = profileAccessToken <|> serverGithubToken -- ensure the ref is in the right format before start the job (commitDate,commitHash) <- getCommitDateAndHash githubToken' fref addField ev $ CreateRunRef fref @@ -228,7 +233,7 @@ server ServerArgs{..} = NamedAPI let ghAccessTokenM = unApiGitHubAccessToken <$> apiGhAccessTokenM -- if there is no github access token, we use the default one -- provided from arguments - ghAccessTokenM' = ghAccessTokenM <|> githubToken + ghAccessTokenM' = ghAccessTokenM <|> serverGithubToken liftIO ( getRepoInfo ghAccessTokenM' owner repo ) >>= fromClientResponse , login = \LoginBody{..} -> whenJWTProvided \JWTConfig{..} -> withEvent eb Login \ev -> do @@ -296,10 +301,12 @@ server ServerArgs{..} = NamedAPI featureTypes <- getNow >>= DB.withDb . DB.getCurrentFeatures profileId addField ev $ GetActiveFeaturesFieldFeatures featureTypes pure featureTypes + , getAdaUsdPrice = withEvent eb GetAdaUsdPrice \ev -> do adaUsdPrice' <- getAdaUsdPrice' addField ev adaUsdPrice' pure adaUsdPrice' + , createAuditorReport = \dryRun reportInput (profileId,_) -> withEvent eb CreateAuditorReport \ev -> do validateFeature L2UploadReport profileId addField ev $ CreateAuditorReportFieldProfileId profileId @@ -310,22 +317,49 @@ server ServerArgs{..} = NamedAPI (fullMetadata,ipfs) <- catch (createMetadataAndPushToIpfs reportInput) handleException addField ev $ CreateAuditorReportIpfsCid ipfs pure fullMetadata + + , getProfileWalletAddress = \(profileId,_) -> withEvent eb GetProfileWalletAddress \ev -> do + addField ev profileId + -- first check the db + walletM <- (id <=< fmap snd) <$> DB.withDb ( DB.getProfileWallet profileId ) + + -- second, if there is nothing in the db try to get + case walletM of + Just (DB.ProfileWallet _ address status _) -> + pure $ Just (status, address) + Nothing -> do + resp <- Wallet.getWalletAddresses serverWalletArgs (Just Wallet.Unused) + case resp of + Right unusedAddressesInfo -> do + let unusedAddresses = fmap (PW.WalletAddress . (.addressId)) unusedAddressesInfo + (walletAddressM,newRotation) <- liftIO (takeMVar serverAddressRotation) + <&> getTemporarilyWalletAddress unusedAddresses profileId + liftIO $ putMVar serverAddressRotation newRotation + pure $ fmap ((DB.Overlapping,) . unWalletAddress) walletAddressM + Left err -> withEvent eb InternalError $ \ev' -> do + addField ev' (show err) + throwError $ err500 {errBody = LSB.pack $ show resp} + } where handleException :: (MonadError ServerError m ) => SomeException -> m a handleException e = do throwError err400 { errBody = LSB.pack $ show e } + getAdaUsdPrice' = - adaUsdPrice >>= maybeToServerError err500 "Can't get ada usd price" + serverAdaUsdPrice >>= maybeToServerError err500 "Can't get ada usd price" + validateFeature featureType profileId = do -- ensure the profile has an active feauture for L1Run - when validateSubscriptions $ do + when serverValidateSubscriptions $ do featureTypes <- getNow >>= DB.withDb . DB.getCurrentFeatures profileId unless (featureType `elem` featureTypes) $ throwError err403 { errBody = "You don't have the required subscription" } + fromClientResponse = \case Left err -> throwError $ serverErrorFromClientError err Right a -> pure a + serverErrorFromClientError :: ClientError -> ServerError serverErrorFromClientError clientResponse = case clientResponse of @@ -396,6 +430,7 @@ server ServerArgs{..} = NamedAPI err = ServerError code "IPFS gateway error" (LSB.fromStrict msg) [] in throwError err Right result -> pure result + getRunAndSync RunID{..} status = do run <- DB.withDb (DB.getRun uuid) >>= maybeToServerError err404 "No Run" diff --git a/src/Plutus/Certification/Server/Internal.hs b/src/Plutus/Certification/Server/Internal.hs index 4ac57017..720f0292 100644 --- a/src/Plutus/Certification/Server/Internal.hs +++ b/src/Plutus/Certification/Server/Internal.hs @@ -1,5 +1,6 @@ {-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE LambdaCase #-} +{-# LANGUAGE TupleSections #-} {-# LANGUAGE GADTs #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE BlockArguments #-} @@ -103,6 +104,8 @@ data ServerEventSelector f where GetActiveFeatures :: ServerEventSelector GetActiveFeaturesField GetAdaUsdPrice :: ServerEventSelector DB.AdaUsdPrice CreateAuditorReport :: ServerEventSelector CreateAuditorReportField + GetProfileWalletAddress :: ServerEventSelector DB.ProfileId + InternalError :: forall a. ToJSON a => ServerEventSelector a renderServerEventSelector :: RenderSelectorJSON ServerEventSelector renderServerEventSelector Version = ("version", absurd) @@ -119,6 +122,7 @@ renderServerEventSelector GenerateGitHubToken = ("generate-github-token", \ (GenerateGitHubTokenError err) -> ("error", toJSON err) ) renderServerEventSelector GetGitHubClientId = ("get-github-client-id", absurd) +renderServerEventSelector GetProfileWalletAddress = ("get-profile-wallet-address", renderProfileId) renderServerEventSelector GetProfileSubscriptions = ("get-profile-subscriptions", renderProfileId) renderServerEventSelector Subscribe = ("subscribe", \case @@ -155,6 +159,8 @@ renderServerEventSelector CreateAuditorReport = ("create-auditor-report", \case CreateAuditorReportDryRun isDryRun -> ("is-dry-run", toJSON isDryRun) CreateAuditorReportIpfsCid cid -> ("cid", toJSON cid) ) +renderServerEventSelector InternalError = + ("internal-error", ("something-went-wrong",) . toJSON ) renderRunIDV1 :: RenderFieldJSON RunIDV1 renderRunIDV1 rid = ("run-id",toJSON rid) diff --git a/src/Plutus/Certification/Synchronizer.hs b/src/Plutus/Certification/Synchronizer.hs index 2df3904e..30282bab 100644 --- a/src/Plutus/Certification/Synchronizer.hs +++ b/src/Plutus/Certification/Synchronizer.hs @@ -26,7 +26,7 @@ import Data.List (groupBy) import Plutus.Certification.API.Routes (RunIDV1(..)) import Plutus.Certification.CoinGeckoClient import Data.Aeson -import Plutus.Certification.TransactionBroadcaster +import Plutus.Certification.CertificationBroadcaster import Observe.Event.Render.JSON import Control.Exception @@ -38,8 +38,10 @@ import Control.Monad.Except (MonadError) import Data.Maybe (fromMaybe) import Observe.Event.Backend import Observe.Event -import Data.IORef import Data.Void +import Plutus.Certification.ProfileWallet +import Data.IORef +import Data.Word (Word64) data InitializingField = WalletArgsField WalletArgs @@ -49,6 +51,7 @@ data SynchronizerSelector f where InitializingSynchronizer :: SynchronizerSelector InitializingField InjectTxBroadcaster :: forall f . !(TxBroadcasterSelector f) -> SynchronizerSelector f InjectCoinGeckoClient :: forall f . !(CoinGeckoClientSelector f) -> SynchronizerSelector f + InjectProfileWalletSync :: forall f . !(ProfileWalletSyncSelector f) -> SynchronizerSelector f MonitorTransactions :: SynchronizerSelector TransactionsCount ActivateSubscriptions :: SynchronizerSelector [DB.SubscriptionId] UpdateAdaPrice :: SynchronizerSelector Void @@ -72,13 +75,14 @@ renderSynchronizerSelector (InjectCoinGeckoClient selector) = renderCoinGeckoCli renderSynchronizerSelector MonitorTransactions = ("monitor-transactions", renderTransactionsCount) renderSynchronizerSelector ActivateSubscriptions = ("activate-subscriptions", renderSubscriptions) renderSynchronizerSelector UpdateAdaPrice = ("refresh-ada-price", absurd) - -renderSubscriptions :: RenderFieldJSON [DB.SubscriptionId] -renderSubscriptions subscriptions = ("subscriptions", toJSON subscriptions) +renderSynchronizerSelector (InjectProfileWalletSync selector) = renderProfileWalletSyncSelector selector renderTransactionsCount :: RenderFieldJSON TransactionsCount renderTransactionsCount (TransactionsCount count) = ("transactions-count",toJSON count) +renderSubscriptions :: RenderFieldJSON [DB.SubscriptionId] +renderSubscriptions subscriptions = ("subscriptions", toJSON subscriptions) + getTimeFromTx :: WalletTransaction -> Maybe UTCTime getTimeFromTx (WalletTransaction _ status)= -- try to extract timestamp from transaction @@ -98,9 +102,8 @@ walletTxStatusToDbStatus Submitted = DB.Submitted synchronizeDbTransactions :: (MonadIO m, MonadMask m) => [WalletTransaction] -> m () synchronizeDbTransactions transactions = do -- filter out the outgoing transactions and sync them with the database - DB.withDb $ forM_ incomingTransactions storeTransaction + DB.withDb $ forM_ transactions storeTransaction where - incomingTransactions = filter ((Incoming ==) . walletTxDirection . walletTxData) transactions storeTransaction tx@WalletTransaction{..} = void $ case getTimeFromTx tx of -- if the transaction does not have a time, is submitted @@ -110,7 +113,9 @@ synchronizeDbTransactions transactions = do let dbTx = DB.Transaction { DB.wtxId = undefined , DB.wtxExternalId = walletTxData.walletTxId.txId - , DB.wtxAmount = walletTxData.walletTxAmount.quantity + -- IMPORTANT: based on the direction of the transaction + -- we set the amount to be positive or negative + , DB.wtxAmount = amountDirection * fromIntegral ( walletTxData.walletTxAmount.quantity ) , DB.wtxTime = time , DB.wtxDepth = maybe (-1) quantity (walletTxData.walletTxDepth) , DB.wtxStatus = walletTxStatusToDbStatus walletTxStatus @@ -118,6 +123,7 @@ synchronizeDbTransactions transactions = do Nothing -> "" Just val -> decodeUtf8 . toStrict . encode $ val } + amountDirection = if walletTxData.walletTxDirection == Incoming then 1 else (-1) inputEntries = fromInputsToDbInputs walletTxData.walletTxInputs outputEntries = fromOutputsToDbOutputs walletTxData.walletTxOutputs -- store the transaction in the database @@ -133,7 +139,8 @@ fromOutputToDbOutput TxOutput{..} = Just $ DB.TransactionEntry { DB.txEntryId = undefined , DB.txEntryTxId = undefined , DB.txEntryAddress = txOutputAddress.unPublicAddress - , DB.txEntryAmount = txOutputAmount.quantity + --TODO: remove conversion after merging with feat/subscription + , DB.txEntryAmount = fromIntegral txOutputAmount.quantity , DB.txEntryIndex = Nothing , DB.txEntryInput = False } @@ -150,7 +157,8 @@ fromInputToDbInput (TxInput index _ (Just TxOutput{..})) { DB.txEntryId = undefined , DB.txEntryTxId = undefined , DB.txEntryAddress = txOutputAddress.unPublicAddress - , DB.txEntryAmount = txOutputAmount.quantity + --TODO: remove conversion after merging with feat/subscription + , DB.txEntryAmount = fromIntegral txOutputAmount.quantity , DB.txEntryIndex = Just index , DB.txEntryInput = True } @@ -158,8 +166,10 @@ fromInputToDbInput (TxInput index _ (Just TxOutput{..})) monitorWalletTransactions :: (MonadIO m, MonadMask m,MonadError IOException m) => EventBackend m r SynchronizerSelector -> WalletArgs + -> Word64 + -> IORef PrevAssignments -> m () -monitorWalletTransactions eb args = withEvent eb MonitorTransactions $ \ev -> do +monitorWalletTransactions eb args minAssignmentAmount refAssignments = withEvent eb MonitorTransactions $ \ev -> do -- fetch the list of transactions from the wallet -- TODO: fetch only the transactions that are not in the database -- or starting from the first pending transaction @@ -167,6 +177,11 @@ monitorWalletTransactions eb args = withEvent eb MonitorTransactions $ \ev -> do addField ev $ TransactionsCount $ length transactions synchronizeDbTransactions transactions activateSubscriptions (subEventBackend ev) + -- synchronize wallets + liftIO (readIORef refAssignments) + >>= resyncWallets (narrowEventBackend InjectProfileWalletSync eb) args minAssignmentAmount + >>= liftIO . writeIORef refAssignments + certifyRuns (subEventBackend ev) args where -- handle the response from the wallet @@ -226,7 +241,7 @@ certifyProfileRuns certificationProcess runs = certifyRuns' [] _ = return () certifyRuns' (run:rs) creditsAvailable = do -- calculate the cost of the run - let cost = run.certificationPrice + let cost = fromIntegral run.certificationPrice -- if we have enough credits, certify the run when (creditsAvailable >= cost) $ void (certificationProcess pid (run.runId)) @@ -243,17 +258,21 @@ startTransactionsMonitor :: (MonadIO m,MonadMask m,MonadError IOException m) -> WalletArgs -> IORef (Maybe DB.AdaUsdPrice) -> Int + -> Word64 -> m b -startTransactionsMonitor eb args adaPriceRef delayInSeconds = +startTransactionsMonitor eb args adaPriceRef delayInSeconds minAssignmentAmount = withEvent eb InitializingSynchronizer $ \ev -> do - addField ev $ WalletArgsField args - addField ev $ DelayField delayInSeconds - -- TODO maybe a forkIO here will be better than into the calling function - -- hence, now, the parent instrumentation event will never terminate - forever $ do - updateAdaPrice (subEventBackend ev) adaPriceRef - monitorWalletTransactions (subEventBackend ev) args - liftIO $ threadDelay delayInMicroseconds + + addField ev $ WalletArgsField args + addField ev $ DelayField delayInSeconds + -- TODO maybe a forkIO here will be better than into the calling function + -- hence, now, the parent instrumentation event will never terminate + ref <- liftIO $ newIORef [] + forever $ do + updateAdaPrice (subEventBackend ev) adaPriceRef + monitorWalletTransactions (subEventBackend ev) args minAssignmentAmount ref + liftIO $ threadDelay delayInMicroseconds + where delayInMicroseconds = delayInSeconds * 1000000 diff --git a/src/Plutus/Certification/WalletClient.hs b/src/Plutus/Certification/WalletClient.hs index 4831fb42..d3ea4878 100644 --- a/src/Plutus/Certification/WalletClient.hs +++ b/src/Plutus/Certification/WalletClient.hs @@ -1,24 +1,29 @@ -{-# LANGUAGE DataKinds #-} -{-# LANGUAGE BlockArguments #-} -{-# LANGUAGE DeriveGeneric #-} -{-# LANGUAGE TypeOperators #-} -{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE BlockArguments #-} +{-# LANGUAGE DeriveGeneric #-} +{-# LANGUAGE TypeOperators #-} +{-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE ExistentialQuantification #-} {-# LANGUAGE InstanceSigs #-} {-# LANGUAGE OverloadedRecordDot #-} {-# LANGUAGE QuasiQuotes #-} {-# LANGUAGE RecordWildCards #-} +{-# LANGUAGE LambdaCase #-} module Plutus.Certification.WalletClient ( TxResponse(..) + , TxId , Amount(..) , WalletArgs(..) , broadcastTransaction , getTransactionList + , getWalletAddresses , CertificationMetadata(..) , WalletAddress , WalletTransaction(..) , Direction(..) + , AddressState(..) + , WalletAddressInfo(..) ) where import Control.Monad.IO.Class @@ -32,10 +37,11 @@ import IOHK.Certification.Persistence import Network.HTTP.Client hiding (Proxy) import Network.HTTP.Client.TLS import Plutus.Certification.WalletClient.Transaction +import Plutus.Certification.Internal import Servant.API import Servant.Client import Data.Int -import Plutus.Certification.Internal +import Data.Maybe data TxBody = forall a . (ToJSON a) => TxBody { passphrase :: !Text @@ -85,11 +91,27 @@ instance ToJSON TxResponse where type API = "v2" :> "wallets" :> Capture "wallet-id" Text - :> "transactions" - :>( ReqBody '[JSON] TxBody :> Verb 'POST 202 '[JSON] TxResponse + :> ( TransactionsAPI :<|> AddressesAPI ) + +type TransactionsAPI = "transactions" + :> ( ReqBody '[JSON] TxBody :> Verb 'POST 202 '[JSON] TxResponse :<|> Get '[JSON] [WalletTransaction] ) +data AddressState = Used | Unused deriving Show +instance FromJSON AddressState where + parseJSON = withText "AddressState" \case + "used" -> pure Used + "unused" -> pure Unused + _ -> fail "Invalid AddressState" +type AddressesAPI = "addresses" :> QueryParam "state" AddressState :> Get '[JSON] [WalletAddressInfo] + +instance ToHttpApiData AddressState where + toUrlPiece :: AddressState -> Text + toUrlPiece Used = "used" + toUrlPiece Unused = "unused" + +--http://localhost:8090/v2/wallets/73857344a0cf884fe044abfe85660cc9a81f6366/addresses?state=used type WalletAddress = Text data WalletArgs = WalletArgs { walletId :: !Text @@ -120,7 +142,8 @@ instance ToJSON CertificationMetadata where ] ++ maybe [] (\x -> [ "twitter" .= split64 x]) crtmTwitter ++ maybe [] (\x -> [ "link" .= (split64 . pack . showBaseUrl $ x )]) crtmLink -mkClient :: Text -> (TxBody -> ClientM TxResponse) :<|> ClientM [WalletTransaction] +mkClient :: Text -> ((TxBody -> ClientM TxResponse) :<|> ClientM [WalletTransaction]) + :<|> (Maybe AddressState -> ClientM [WalletAddressInfo]) mkClient = client (Proxy :: Proxy API) mkSettings :: MonadIO m => BaseUrl -> m ClientEnv @@ -131,14 +154,15 @@ mkSettings walletAPIAddress = liftIO $ do type MetadataKey = Int broadcastTransaction :: (MonadIO m, ToJSON metadata) => WalletArgs + -> Maybe WalletAddress -> MetadataKey -> metadata -> m (Either ClientError TxResponse) -broadcastTransaction WalletArgs{..} metadataKey metadata = liftIO $ do +broadcastTransaction WalletArgs{..} destAddr metadataKey metadata = liftIO $ do settings <- mkSettings walletAPIAddress - let broadcastTx :<|> _ = mkClient walletId + let (broadcastTx :<|> _) :<|> _ = mkClient walletId metadataKeyStr = show metadataKey - let body = TxBody walletPassphrase walletAddress [aesonQQ| { $metadataKeyStr: #{ metadata }} |] + body = TxBody walletPassphrase (fromMaybe walletAddress destAddr ) [aesonQQ| { $metadataKeyStr: #{ metadata }} |] runClientM (broadcastTx body ) settings getTransactionList :: (MonadIO m) @@ -146,5 +170,25 @@ getTransactionList :: (MonadIO m) -> m (Either ClientError [WalletTransaction]) getTransactionList WalletArgs{..} = liftIO $ do settings <- mkSettings walletAPIAddress - let _ :<|> getList = mkClient walletId + let (_ :<|> getList) :<|> _ = mkClient walletId runClientM getList settings + +data WalletAddressInfo = WalletAddressInfo + { derivationPath :: [Text] + , addressId :: WalletAddress + , addressState :: AddressState + } deriving Show + +instance FromJSON WalletAddressInfo where + parseJSON = withObject "WalletAddressInfo" \o -> WalletAddressInfo + <$> o .: "derivation_path" + <*> o .: "id" + <*> o .: "state" +getWalletAddresses :: (MonadIO m) + => WalletArgs + -> Maybe AddressState + -> m (Either ClientError [WalletAddressInfo]) +getWalletAddresses WalletArgs{..} state = liftIO $ do + settings <- mkSettings walletAPIAddress + let (_ :<|> _) :<|> getAddressList = mkClient walletId + runClientM (getAddressList state) settings