haskoin-wallet-0.9.4: src/Haskoin/Wallet/Backup.hs
{-# LANGUAGE CPP #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE OverloadedRecordDot #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}
module Haskoin.Wallet.Backup where
import Conduit (MonadUnliftIO)
import Control.Monad
import Control.Monad.Except
import Control.Monad.Reader (MonadIO (..), MonadTrans (lift))
import Data.Aeson (object, (.:), (.=))
import qualified Data.Aeson as Json
import Data.Default (def)
import Data.List (nub, sort, (\\))
import Data.Map.Strict (Map)
import qualified Data.Map.Strict as Map
import Data.Maybe (fromJust)
import qualified Data.Serialize as S
import Data.Text (Text)
import Data.Time (UTCTime)
import Database.Esqueleto.Legacy as E
import Haskoin
import qualified Haskoin.Store.Data as Store
import Haskoin.Store.WebClient
import Haskoin.Wallet.Config
import Haskoin.Wallet.Database
import Haskoin.Wallet.TxInfo
import Haskoin.Wallet.Util
import Numeric.Natural (Natural)
newtype WalletBackup = WalletBackup [AccountBackup]
deriving (Eq, Show)
instance MarshalJSON Ctx WalletBackup where
marshalValue ctx (WalletBackup accs) =
object ["accounts" .= (marshalValue ctx <$> accs)]
unmarshalValue ctx =
Json.withObject "WalletBackup" $ \o -> do
accs <- mapM (unmarshalValue ctx) =<< o .: "accounts"
return $ WalletBackup accs
data AccountBackup = AccountBackup
{ accountBackupName :: !Text,
accountBackupWallet :: !Fingerprint,
accountBackupPubKey :: !XPubKey,
accountBackupNetwork :: !Network,
accountBackupExternal :: !Natural,
accountBackupInternal :: !Natural,
accountBackupLabels :: Map Address Text,
accountBackupFree :: [Address],
accountBackupCreated :: !UTCTime
}
deriving (Eq, Show)
instance MarshalJSON Ctx AccountBackup where
marshalValue ctx AccountBackup {..} =
let net = accountBackupNetwork
f = Map.mapKeys (fromJust . addrToText net)
in object
[ "name" .= accountBackupName,
"wallet" .= accountBackupWallet,
"xpubkey"
.= marshalValue (accountBackupNetwork, ctx) accountBackupPubKey,
"network" .= accountBackupNetwork.name,
"external" .= accountBackupExternal,
"internal" .= accountBackupInternal,
"labels" .= f accountBackupLabels,
"free" .= (fromJust . addrToText net <$> accountBackupFree),
"created" .= accountBackupCreated
]
unmarshalValue ctx =
Json.withObject "AccountBackup" $ \o -> do
net <- maybe mzero pure . netByName =<< o .: "network"
let f = Map.mapKeys (fromJust . textToAddr net)
AccountBackup
<$> o .: "name"
<*> o .: "wallet"
<*> (unmarshalValue (net, ctx) =<< o .: "xpubkey")
<*> pure net
<*> o .: "external"
<*> o .: "internal"
<*> (f <$> o .: "labels")
<*> ((fromJust . textToAddr net <$>) <$> o .: "free")
<*> o .: "created"
createBackup :: (MonadUnliftIO m) => Ctx -> ExceptT String (DB m) WalletBackup
createBackup ctx = do
accs <- lift getAccounts
backs <-
forM accs $ \(_, acc@DBAccount {..}) -> do
let net = accountNetwork acc
(DBWalletKey fpT) = dBAccountWallet
fp <- liftEither $ textToFingerprint fpT
labelVals <-
lift . select . from $ \a -> do
where_ $
a ^. DBAddressAccountWallet ==. val dBAccountWallet
&&. a ^. DBAddressAccountDerivation ==. val dBAccountDerivation
&&. a ^. DBAddressInternal ==. val False
&&. a ^. DBAddressLabel !=. val ""
return (a ^. DBAddressAddress, a ^. DBAddressLabel)
labels <- forM labelVals $ \(Value at, Value l) -> do
a <- liftMaybe "Address" $ textToAddr net at
return (a, l)
freeVals <-
lift . select . from $ \a -> do
where_ $
a ^. DBAddressAccountWallet ==. val dBAccountWallet
&&. a ^. DBAddressAccountDerivation ==. val dBAccountDerivation
&&. a ^. DBAddressInternal ==. val True
&&. a ^. DBAddressFree ==. val True
return $ a ^. DBAddressAddress
free <- mapM (liftMaybe "Address" . textToAddr net . unValue) freeVals
return
AccountBackup
{ accountBackupName = dBAccountName,
accountBackupWallet = fp,
accountBackupPubKey = accountXPubKey ctx acc,
accountBackupNetwork = accountNetwork acc,
accountBackupExternal = fromIntegral dBAccountExternal,
accountBackupInternal = fromIntegral dBAccountInternal,
accountBackupLabels = Map.fromList labels,
accountBackupFree = free,
accountBackupCreated = dBAccountCreated
}
return $ WalletBackup backs
data SyncRes = SyncRes
{ syncResAccount :: !DBAccount,
syncResBlockHash :: !BlockHash,
syncResBlockHeight :: !BlockHeight,
syncResTxUpdates :: !Natural,
syncResCoinUpdates :: !Natural
}
deriving (Eq, Show)
restoreBackup ::
(MonadUnliftIO m) =>
Ctx ->
Config ->
WalletBackup ->
ExceptT String (DB m) [SyncRes]
restoreBackup ctx cfg (WalletBackup accs) =
mapM (restoreAccount ctx cfg) accs
restoreAccount ::
(MonadUnliftIO m) =>
Ctx ->
Config ->
AccountBackup ->
ExceptT String (DB m) SyncRes
restoreAccount ctx cfg AccountBackup {..} = do
-- Insert the account
(accId, acc) <-
insertAccount
accountBackupNetwork
ctx
accountBackupWallet
accountBackupName
accountBackupPubKey
let net = accountBackupNetwork
-- Set the external and internal derivation indices
(e, i) <- discoverAddrs net ctx cfg accountBackupPubKey
let idxE = max e accountBackupExternal
idxI = max i accountBackupInternal
discoverAccGenAddrs ctx cfg accId AddrExternal $ fromIntegral idxE
discoverAccGenAddrs ctx cfg accId AddrInternal $ fromIntegral idxI
-- Perform an account sync
syncRes <- sync ctx cfg net accId True
-- Set address labels
forM_ (Map.assocs accountBackupLabels) $ \(addr, l) -> do
at <- liftMaybe "Address" $ addrToText net addr
lift . update $ \a -> do
set a [DBAddressLabel =. val l]
where_ $ a ^. DBAddressAddress ==. val at
-- Set Free internal addresses
addrsT <- mapM (liftMaybe "Address" . addrToText net) accountBackupFree
_ <- lift $ setAddrsFree AddrFree addrsT
-- Set the creation time
lift . update $ \a -> do
set a [DBAccountCreated =. val accountBackupCreated]
where_ $
a ^. DBAccountWallet ==. val (dBAccountWallet acc)
&&. a ^. DBAccountDerivation ==. val (dBAccountDerivation acc)
acc' <- getAccountById accId
return syncRes {syncResAccount = acc'}
discoverAddrs ::
(MonadIO m) =>
Network ->
Ctx ->
Config ->
XPubKey ->
ExceptT String m (Natural, Natural)
discoverAddrs net ctx cfg pub = do
let recoveryGap = configRecoveryGap cfg
e <- go extDeriv 0 (Page recoveryGap 0)
i <- go intDeriv 0 (Page recoveryGap 0)
return (fromIntegral e, fromIntegral i)
where
go path d page@(Page lim off) = do
let addrs = addrsDerivPage ctx path page pub
req = GetAddrsBalance $ fst <$> addrs
let host = apiHost net cfg
Store.SerialList bals <- liftExcept $ apiCall ctx host req
let vBals = filter ((/= 0) . (.txs)) bals
if null vBals
then return d
else do
let dMax = findMax addrs $ (.address) <$> vBals
go path (dMax + 1) (Page lim (off + lim))
-- Find the largest ID amongst the addresses that have a positive balance
findMax :: [(Address, SoftPath)] -> [Address] -> Int
findMax addrs balAddrs =
let fAddrs = filter ((`elem` balAddrs) . fst) addrs
in fromIntegral $ maximum $ last . pathToList . snd <$> fAddrs
sync ::
(MonadUnliftIO m) =>
Ctx ->
Config ->
Network ->
DBAccountId ->
Bool ->
ExceptT String (DB m) SyncRes
sync ctx cfg net accId full = do
let host = apiHost net cfg
-- Check API health
checkHealth ctx net cfg
-- Get the new best block before starting the sync
best <- liftExcept $ apiCall ctx host (GetBlockBest def)
-- Get the addresses from our local database
(addrPathMap, addrBalMap) <- allAddressesMap net accId
-- Fetch the address balances online
Store.SerialList storeBals <-
liftExcept . apiBatch ctx (configAddrBatch cfg) host $
GetAddrsBalance (Map.keys addrBalMap)
-- Filter only those addresses whose balances have changed
balsToUpdate <-
if full
then return storeBals
else liftEither $ filterAddresses storeBals addrBalMap
let addrsToUpdate = (.address) <$> balsToUpdate
-- Update balances
updateAddressBalances net balsToUpdate
newAcc <- lift $ updateAccountBalances accId
-- Get a list of our confirmed txs in the local database
-- Use an empty list when doing a full sync
confirmedTxs <- if full then return [] else getConfirmedTxs accId True
-- Fetch the txids of the addresses to update
aTids <- searchAddrTxs net ctx cfg confirmedTxs addrsToUpdate
-- We also want to check if there is any change in unconfirmed txs
uTids <- getConfirmedTxs accId False
let tids = nub $ uTids <> aTids
-- Fetch the full transactions
Store.SerialList txs <-
liftExcept $ apiBatch ctx (configTxFullBatch cfg) host (GetTxs tids)
-- Convert them to TxInfo and store them in the local database
let txInfos = storeToTxInfo addrPathMap (fromIntegral best.height) <$> txs
resTxInfo <- forM txInfos $ repsertTxInfo net ctx accId
-- Fetch and update coins
Store.SerialList storeCoins <-
liftExcept . apiBatch ctx (configCoinBatch cfg) host $
GetAddrsUnspent addrsToUpdate def
(coinCount, newCoins) <- refreshCoins net accId addrsToUpdate storeCoins
-- Get the dependent tranactions of the new coins
depTxsHash <-
if full
then return $ (.outpoint.hash) <$> storeCoins
else mapM (liftEither . coinToTxHash) newCoins
Store.RawResultList rawTxs <-
liftExcept
. apiBatch ctx (configTxFullBatch cfg) host
$ GetTxsRaw
$ nub depTxsHash
lift $ forM_ rawTxs insertRawTx
-- Remove pending transactions if they are online
pendingTids <- pendingTxHashes accId
let toRemove = filter ((`elem` tids) . fst) pendingTids
forM_ toRemove $ \(_, key) -> lift $ deletePendingTxOnline key
-- Update the best block for this network
lift $ updateBest net (headerHash best.header) best.height
return $
SyncRes
newAcc
(headerHash best.header)
(fromIntegral best.height)
(fromIntegral $ length $ filter id $ snd <$> resTxInfo)
(fromIntegral coinCount)
-- Filter addresses that need to be updated
filterAddresses ::
[Store.Balance] ->
Map Address AddressBalance ->
Either String [Store.Balance]
filterAddresses sBals aMap
| sort ((.address) <$> sBals) /= sort (Map.keys aMap) =
Left "Sync: addresses do not match"
| otherwise =
Right $ filter f sBals
where
f s =
let b = fromJust $ s.address `Map.lookup` aMap
in s.txs /= addrBalanceTxs b
|| s.confirmed /= addrBalanceConfirmed b
|| s.unconfirmed /= addrBalanceUnconfirmed b
|| s.utxo /= addrBalanceCoins b
searchAddrTxs ::
(MonadIO m) =>
Network ->
Ctx ->
Config ->
[TxHash] ->
[Address] ->
ExceptT String m [TxHash]
searchAddrTxs _ _ _ _ [] = return []
searchAddrTxs net ctx cfg confirmedTxs as
| length as > fromIntegral (configAddrBatch cfg) =
nub . concat <$> mapM (go Nothing 0) (chunksOf (configAddrBatch cfg) as)
| otherwise =
nub <$> go Nothing 0 as
where
go hashM offset' xs = do
Store.SerialList txRefs <-
liftExcept $
apiCall
ctx
(apiHost net cfg)
( GetAddrsTxs
xs
def
{ limit = Just $ fromIntegral (configTxBatch cfg),
start = StartParamHash <$> hashM,
offset = offset'
}
)
-- Remove txs that we already have
let tids = ((.txid) <$> txRefs) \\ confirmedTxs
-- Either we have reached the end of the stream, or we have hit some
-- txs in confirmedTxs. In both cases, we can stop the search.
if length tids < fromIntegral (configTxBatch cfg)
then return tids
else do
let lastId = (last tids).get
rest <- go (Just lastId) 1 xs
return $ tids <> rest
coinToTxHash :: DBCoin -> Either String TxHash
coinToTxHash coin =
maybeToEither "coinToTxHash: Invalid outpoint" $ do
bs <- decodeHex $ dBCoinOutpoint coin
op <- eitherToMaybe (S.decode bs) :: Maybe OutPoint
return op.hash