2020 1 11 accounts (#1022)

* Create 'fundRawTransaction' and 'fundRawTransactionInternal'

* Move TransactionTestUtil to testkit, begin writing tests for fundrawtransaction

* Add FundTransactionTests

* Move things to 'FundTransactionHandling' trait

* Start segregating things by HDAccount in the wallet, this gives us the ability to query for balances based on account, fund a transaction based on an account, generate a new address based on an account etc. All old api calls are now based on the default account, i.e. getBalance() returns the balance for account 0, fundRawTransaction funds the transaction from account 0 etc.

* Fix compile issue with 2.11.x

* Bump address generation Thread.sleep() to 500ms

* Address ben code review

* Address code review
This commit is contained in:
Chris Stewart 2020-01-21 14:20:58 -06:00 committed by GitHub
parent 603951ea53
commit b8c59b4c93
22 changed files with 578 additions and 155 deletions

View File

@ -80,7 +80,7 @@ class RoutesSpec
}
"return the wallet's balance" in {
(mockWalletApi.getBalance _)
(mockWalletApi.getBalance: () => Future[CurrencyUnit])
.expects()
.returning(Future.successful(Bitcoins(50)))

View File

@ -0,0 +1,65 @@
package org.bitcoins.core.hd
import org.bitcoins.testkit.core.gen.HDGenerators
import org.bitcoins.testkit.util.BitcoinSUnitTest
import org.bitcoins.testkit.wallet.WalletTestUtil
class HDAccountTest extends BitcoinSUnitTest {
behavior of "HDAccount"
override implicit val generatorDrivenConfig: PropertyCheckConfiguration = {
generatorDrivenConfigNewCode
}
val defaultAcct = WalletTestUtil.defaultHdAccount
val defaultPath = defaultAcct.path
it must "determine if a bip32 path is the same as a given HDAccount" in {
val isSameDefault = HDAccount.isSameAccount(defaultPath, defaultAcct)
assert(isSameDefault, s"Must have symmetry")
}
it must "fail if the given path is shorter than how BIP44 defines accounts" in {
val missingLast = defaultPath.dropRight(1)
val isNotSame = !HDAccount.isSameAccount(missingLast, defaultAcct)
assert(isNotSame, s"If we drop the last element from the defualt path, we are not in the same account anymore")
}
it must "fail if we modify the last element in the path" in {
val newLast = defaultPath.last.copy(index = Int.MaxValue)
val modifiedLast = defaultPath.updated(defaultPath.length - 1, newLast)
val isNotSame = !HDAccount.isSameAccount(modifiedLast, defaultAcct)
assert(isNotSame, s"We should have the same account if we modify the account index")
}
it must "succeed if we add an arbitrary element onto the end of the path" in {
val extraNode = defaultPath.:+(BIP32Node(0, true))
val isSame = HDAccount.isSameAccount(extraNode, defaultAcct)
assert(isSame, s"If we add an extra element onto the path, we are still in the same account")
}
it must "fail with the empty path" in {
val empty = BIP32Path.empty
val isNotSame = !HDAccount.isSameAccount(empty, defaultAcct)
assert(isNotSame)
}
it must "have symmetry for isSameAccount with all hdaccount" in {
forAll(HDGenerators.hdAccount) { acct =>
val path = acct.path
assert(HDAccount.isSameAccount(path, acct))
}
}
it must "not taken an arbitrary path and arbitrary account and find them in the same account" in {
forAll(HDGenerators.hdAccount, HDGenerators.bip32Path) { case (acct, path) =>
assert(!HDAccount.isSameAccount(path, acct))
}
}
}

View File

@ -7,6 +7,8 @@ package org.bitcoins.core.hd
* and
* [[https://github.com/bitcoin/bips/blob/master/bip-0049.mediawiki BIP49]]
* account
*
* m / purpose' / coin_type' / account'
*/
case class HDAccount(
coin: HDCoin,
@ -23,3 +25,34 @@ case class HDAccount(
def toChain(chainType: HDChainType): HDChain =
HDChain(chainType = chainType, account = this)
}
object HDAccount {
/** This method is meant to take in an arbitrary bip32 path and see
* if it has the same account as the given account
*
* This is tricky as an account is defined as
* m / purpose' / cointype' / account'
*
* whereas a bip32 path can be arbitrarily deep.
*
* We want to just check the first 4 elements of the path
* and see if they are the same, which indicates we are in
* the same account
* */
def isSameAccount(path: Vector[BIP32Node], account: HDAccount): Boolean = {
if (account.path.length > path.length) {
false
} else {
val zipped = path.zip(account.path)
zipped.foldLeft(true) {
case (past, (bip32Node, accountNode)) =>
past && bip32Node == accountNode
}
}
}
def isSameAccount(bip32Path: BIP32Path, account: HDAccount): Boolean = {
isSameAccount(bip32Path.path, account)
}
}

View File

@ -2,6 +2,10 @@ package org.bitcoins.core.hd
/**
* Address chain (external vs. change) used by
*
* Format:
* m / purpose' / coin_type' / account' / change
*
* [[https://github.com/bitcoin/bips/blob/master/bip-0044.mediawiki#change BIP44]],
* [[https://github.com/bitcoin/bips/blob/master/bip-0084.mediawiki BIP84]]
* and

View File

@ -5,6 +5,10 @@ package org.bitcoins.core.hd
*
* This has been used for deploying keychains that are compatible with
* raw segwit, p2sh wrapped segwit, and raw scripts.
*
* Format:
* m / purpose'
*
* @see [[https://github.com/bitcoin/bips/blob/master/bip-0043.mediawiki BIP43]]
* @see [[https://github.com/bitcoin/bips/blob/master/bip-0044.mediawiki#Purpose BIP44]]
* @see [[https://github.com/bitcoin/bips/blob/master/bip-0084.mediawiki BIP84]]

View File

@ -8,7 +8,6 @@ import org.bitcoins.core.crypto.{DoubleSha256Digest, DoubleSha256DigestBE}
import org.bitcoins.core.currency._
import org.bitcoins.core.gcs.BlockFilter
import org.bitcoins.core.protocol.BlockStamp
import org.bitcoins.core.protocol.transaction.TransactionOutput
import org.bitcoins.core.util.FutureUtil
import org.bitcoins.db.AppConfig
import org.bitcoins.keymanager.KeyManagerTestUtil
@ -18,11 +17,12 @@ import org.bitcoins.server.BitcoinSAppConfig
import org.bitcoins.server.BitcoinSAppConfig._
import org.bitcoins.testkit.BitcoinSTestAppConfig
import org.bitcoins.testkit.fixtures.BitcoinSFixture
import org.bitcoins.testkit.util.{FileUtil, TransactionTestUtil}
import org.bitcoins.testkit.util.FileUtil
import org.bitcoins.testkit.wallet.FundWalletUtil.FundedWallet
import org.bitcoins.wallet.api.{LockedWalletApi, UnlockedWalletApi}
import org.bitcoins.wallet.config.WalletAppConfig
import org.bitcoins.wallet.db.WalletDbManagement
import org.bitcoins.wallet.{LockedWallet, Wallet, WalletLogger}
import org.bitcoins.wallet.{Wallet, WalletLogger}
import org.scalatest._
import scala.concurrent.{ExecutionContext, Future}
@ -34,6 +34,10 @@ trait BitcoinSWalletTest extends BitcoinSFixture with WalletLogger {
implicit protected def config: BitcoinSAppConfig =
BitcoinSTestAppConfig.getSpvTestConfig()
implicit protected def walletAppConfig: WalletAppConfig = {
config.walletConf
}
override def beforeAll(): Unit = {
AppConfig.throwIfDefaultDatadir(config.walletConf)
}
@ -133,7 +137,7 @@ trait BitcoinSWalletTest extends BitcoinSFixture with WalletLogger {
* underlying blockchain */
def withFundedWallet(test: OneArgAsyncTest): FutureOutcome = {
makeDependentFixture(
build = () => createFundedWallet(nodeApi, chainQueryApi),
build = () => FundWalletUtil.createFundedWallet(nodeApi, chainQueryApi),
destroy = { funded: FundedWallet =>
destroyWallet(funded.wallet)
}
@ -159,6 +163,12 @@ trait BitcoinSWalletTest extends BitcoinSFixture with WalletLogger {
createDefaultWallet(nodeApi, chainQueryApi)
}, destroy = destroyWallet)(test)
def withNewWallet2Accounts(test: OneArgAsyncTest): FutureOutcome = {
makeDependentFixture(build = { () =>
createWallet2Accounts(nodeApi, chainQueryApi)
}, destroy = destroyWallet)(test)
}
def withNewWalletAndBitcoind(test: OneArgAsyncTest): FutureOutcome = {
val builder: () => Future[WalletWithBitcoind] = composeBuildersAndWrap(
builder = { () =>
@ -271,7 +281,7 @@ object BitcoinSWalletTest extends WalletLogger {
nodeApi: NodeApi,
chainQueryApi: ChainQueryApi)(
implicit config: BitcoinSAppConfig,
ec: ExecutionContext): () => Future[UnlockedWalletApi] =
ec: ExecutionContext): () => Future[Wallet] =
() => {
val defaultConf = config.walletConf
val walletConfig = extraConfig match {
@ -291,11 +301,9 @@ object BitcoinSWalletTest extends WalletLogger {
}
/** Creates a wallet with the default configuration */
private def createDefaultWallet(
nodeApi: NodeApi,
chainQueryApi: ChainQueryApi)(
def createDefaultWallet(nodeApi: NodeApi, chainQueryApi: ChainQueryApi)(
implicit config: BitcoinSAppConfig,
ec: ExecutionContext): Future[UnlockedWalletApi] = {
ec: ExecutionContext): Future[Wallet] = {
val bip39PasswordOpt = KeyManagerTestUtil.bip39PasswordOpt
val km = createNewKeyManager(bip39PasswordOpt = bip39PasswordOpt)
createNewWallet(
@ -306,20 +314,18 @@ object BitcoinSWalletTest extends WalletLogger {
chainQueryApi = chainQueryApi)(config, ec)() // get the standard config
}
/** This wallet should have a total of 6 bitcoin in it
* spread across 3 utxos that have values 1, 2, 3 bitcoins */
case class FundedWallet(wallet: LockedWallet)
/** This creates a wallet that is funded that is not paired to a bitcoind instance. */
def createFundedWallet(nodeApi: NodeApi, chainQueryApi: ChainQueryApi)(
def createWallet2Accounts(nodeApi: NodeApi, chainQueryApi: ChainQueryApi)(
implicit config: BitcoinSAppConfig,
system: ActorSystem): Future[FundedWallet] = {
import system.dispatcher
ec: ExecutionContext): Future[Wallet] = {
val defaultWalletF = createDefaultWallet(nodeApi, chainQueryApi)
for {
wallet <- createDefaultWallet(nodeApi, chainQueryApi)
funded <- fundWallet(wallet)
} yield funded
wallet <- defaultWalletF
account1 = WalletTestUtil.getHdAccount1(wallet.walletConfig)
newAccountWallet <- wallet.createNewAccount(hdAccount = account1,
kmParams =
wallet.keyManager.kmParams)
} yield newAccountWallet
}
/** Pairs the given wallet with a bitcoind instance that has money in the bitcoind wallet */
@ -376,48 +382,6 @@ object BitcoinSWalletTest extends WalletLogger {
} yield funded
}
/** Funds a bitcoin-s wallet with 3 utxos with 1, 2 and 3 bitcoin in the utxos */
def fundWallet(wallet: UnlockedWalletApi)(
implicit ec: ExecutionContext): Future[FundedWallet] = {
//get three addresses
val addressesF = Future.sequence(Vector.fill(3) {
//this Thread.sleep is needed because of
//https://github.com/bitcoin-s/bitcoin-s/issues/1009
//once that is resolved we should be able to remove this
Thread.sleep(500)
wallet.getNewAddress()
})
//construct three txs that send money to these addresses
//these are "fictional" transactions in the sense that the
//outpoints do not exist on a blockchain anywhere
val amounts = Vector(1.bitcoin, 2.bitcoin, 3.bitcoin)
val expectedAmt = amounts.fold(CurrencyUnits.zero)(_ + _)
val txsF = for {
addresses <- addressesF
} yield {
addresses.zip(amounts).map {
case (addr, amt) =>
val output =
TransactionOutput(value = amt, scriptPubKey = addr.scriptPubKey)
TransactionTestUtil.buildTransactionTo(output)
}
}
val fundedWalletF =
txsF.flatMap(txs =>
wallet.processTransactions(transactions = txs, blockHash = None))
//sanity check to make sure we have money
for {
fundedWallet <- fundedWalletF
balance <- fundedWallet.getBalance()
_ = require(
balance == 6.bitcoin,
s"Funding wallet fixture failed ot fund the wallet, got balance=${balance} expected=${expectedAmt}")
} yield FundedWallet(fundedWallet.asInstanceOf[LockedWallet])
}
/** Funds the given wallet with money from the given bitcoind */
def fundWalletWithBitcoind(pair: WalletWithBitcoind)(
implicit ec: ExecutionContext): Future[WalletWithBitcoind] = {

View File

@ -0,0 +1,124 @@
package org.bitcoins.testkit.wallet
import akka.actor.ActorSystem
import org.bitcoins.core.api.{ChainQueryApi, NodeApi}
import org.bitcoins.core.currency.{Bitcoins, CurrencyUnit, CurrencyUnits, _}
import org.bitcoins.core.hd.HDAccount
import org.bitcoins.core.protocol.BitcoinAddress
import org.bitcoins.core.protocol.transaction.TransactionOutput
import org.bitcoins.server.BitcoinSAppConfig
import org.bitcoins.testkit.util.TransactionTestUtil
import org.bitcoins.testkit.wallet.FundWalletUtil.FundedWallet
import org.bitcoins.wallet.{LockedWallet, Wallet}
import scala.concurrent.{ExecutionContext, Future}
trait FundWalletUtil {
def fundAccountForWallet(
amts: Vector[CurrencyUnit],
account: HDAccount,
wallet: Wallet)(implicit ec: ExecutionContext): Future[Wallet] = {
val init = Future.successful(Vector.empty[BitcoinAddress])
val addressesF: Future[Vector[BitcoinAddress]] = 0.until(3).foldLeft(init) {
case (accumF, _) =>
//this Thread.sleep is needed because of
//https://github.com/bitcoin-s/bitcoin-s/issues/1009
//once that is resolved we should be able to remove this
for {
accum <- accumF
address <- wallet.getNewAddress(account)
} yield {
accum.:+(address)
}
}
//construct three txs that send money to these addresses
//these are "fictional" transactions in the sense that the
//outpoints do not exist on a blockchain anywhere
val txsF = for {
addresses <- addressesF
} yield {
addresses.zip(amts).map {
case (addr, amt) =>
val output =
TransactionOutput(value = amt, scriptPubKey = addr.scriptPubKey)
TransactionTestUtil.buildTransactionTo(output)
}
}
val fundedWalletF =
txsF.flatMap(txs => wallet.processTransactions(txs, None))
fundedWalletF.map(_.asInstanceOf[Wallet])
}
/** Funds a bitcoin-s wallet with 3 utxos with 1, 2 and 3 bitcoin in the utxos */
def fundWallet(wallet: Wallet)(
implicit ec: ExecutionContext): Future[FundedWallet] = {
val defaultAcctAmts = Vector(1.bitcoin, 2.bitcoin, 3.bitcoin)
val expectedDefaultAmt = defaultAcctAmts.fold(CurrencyUnits.zero)(_ + _)
val account1Amt = Vector(Bitcoins(0.2), Bitcoins(0.3), Bitcoins(0.5))
val expectedAccount1Amt = account1Amt.fold(CurrencyUnits.zero)(_ + _)
val defaultAccount = wallet.walletConfig.defaultAccount
val fundedDefaultAccountWalletF = FundWalletUtil.fundAccountForWallet(
amts = defaultAcctAmts,
account = defaultAccount,
wallet = wallet
)
val hdAccount1 = WalletTestUtil.getHdAccount1(wallet.walletConfig)
val fundedAccount1WalletF = for {
fundedDefaultAcct <- fundedDefaultAccountWalletF
fundedAcct1 <- FundWalletUtil.fundAccountForWallet(
amts = account1Amt,
account = hdAccount1,
fundedDefaultAcct
)
} yield fundedAcct1
//sanity check to make sure we have money
for {
fundedWallet <- fundedAccount1WalletF
balance <- fundedWallet.getBalance(defaultAccount)
_ = require(
balance == expectedDefaultAmt,
s"Funding wallet fixture failed to fund the wallet, got balance=${balance} expected=${expectedDefaultAmt}")
account1Balance <- fundedWallet.getBalance(hdAccount1)
_ = require(
account1Balance == expectedAccount1Amt,
s"Funding wallet fixture failed to fund account 1, " +
s"got balance=${hdAccount1} expected=${expectedAccount1Amt}"
)
} yield FundedWallet(fundedWallet.asInstanceOf[LockedWallet])
}
}
object FundWalletUtil extends FundWalletUtil {
/** This is a wallet that was two funded accounts
* Account 0 (default account) has utxos of 1,2,3 bitcoin in it (6 btc total)
* Account 1 has a utxos of 0.2,0.3,0.5 bitcoin in it (0.6 total)
* */
case class FundedWallet(wallet: LockedWallet)
/** This creates a wallet that was two funded accounts
* Account 0 (default account) has utxos of 1,2,3 bitcoin in it (6 btc total)
* Account 1 has a utxos of 0.2,0.3,0.5 bitcoin in it (1 btc total)
* */
def createFundedWallet(nodeApi: NodeApi, chainQueryApi: ChainQueryApi)(
implicit config: BitcoinSAppConfig,
system: ActorSystem): Future[FundedWallet] = {
import system.dispatcher
for {
wallet <- BitcoinSWalletTest.createWallet2Accounts(nodeApi, chainQueryApi)
funded <- FundWalletUtil.fundWallet(wallet)
} yield funded
}
}

View File

@ -24,6 +24,7 @@ import org.bitcoins.core.wallet.utxo.TxoState
import org.bitcoins.testkit.Implicits._
import org.bitcoins.testkit.core.gen.{CryptoGenerators, NumberGenerator}
import org.bitcoins.testkit.fixtures.WalletDAOs
import org.bitcoins.wallet.config.WalletAppConfig
import org.bitcoins.wallet.models._
import scala.concurrent.{ExecutionContext, Future}
@ -69,8 +70,14 @@ object WalletTestUtil {
private def freshXpub(): ExtPublicKey =
CryptoGenerators.extPublicKey.sampleSome
val firstAccount = HDAccount(HDCoin(HDPurposes.SegWit, hdCoinType), 0)
def firstAccountDb = AccountDb(freshXpub(), firstAccount)
val defaultHdAccount = HDAccount(HDCoin(HDPurposes.SegWit, hdCoinType), 0)
def getHdAccount1(walletAppConfig: WalletAppConfig): HDAccount = {
val purpose = walletAppConfig.defaultAccountKind
HDAccount(coin = HDCoin(purpose, HDCoinType.Testnet), index = 1)
}
def firstAccountDb = AccountDb(freshXpub(), defaultHdAccount)
private def randomScriptWitness: ScriptWitness =
P2WPKHWitnessV0(freshXpub().key)

View File

@ -0,0 +1,42 @@
package org.bitcoins.wallet
import org.bitcoins.testkit.wallet.{BitcoinSWalletTest, WalletTestUtil}
import org.scalatest.FutureOutcome
class AddressHandlingTest extends BitcoinSWalletTest {
type FixtureParam = Wallet
override def withFixture(test: OneArgAsyncTest): FutureOutcome = {
withNewWallet2Accounts(test)
}
behavior of "AddressHandling"
it must "generate a new address for the default account and then find it" in { wallet: Wallet =>
val addressF = wallet.getNewAddress()
for {
address <- addressF
exists <- wallet.contains(address, None)
} yield {
assert(exists, s"Wallet must contain address after generating it")
}
}
it must "generate an address for a non default account and then find it" in { wallet: Wallet =>
val account1 = WalletTestUtil.getHdAccount1(wallet.walletConfig)
val addressF = wallet.getNewAddress(account1)
for {
address <- addressF
listAddressesForAcct <- wallet.listAddresses(account1)
exists <- wallet.contains(address, Some(account1))
doesNotExist <- wallet.contains(address, None)
} yield {
assert(listAddressesForAcct.nonEmpty)
assert(listAddressesForAcct.map(_.address).contains(address))
assert(exists, s"Wallet must contain address in specific after generating it")
assert(doesNotExist, s"Wallet must NOT contain address in default account when address is specified")
}
}
}

View File

@ -4,8 +4,8 @@ import org.bitcoins.core.currency.Bitcoins
import org.bitcoins.core.protocol.transaction.TransactionOutput
import org.bitcoins.core.wallet.fee.SatoshisPerVirtualByte
import org.bitcoins.testkit.util.TestUtil
import org.bitcoins.testkit.wallet.BitcoinSWalletTest
import org.bitcoins.testkit.wallet.BitcoinSWalletTest.FundedWallet
import org.bitcoins.testkit.wallet.{BitcoinSWalletTest, WalletTestUtil}
import org.bitcoins.testkit.wallet.FundWalletUtil.FundedWallet
import org.scalatest.FutureOutcome
class FundTransactionHandlingTest extends BitcoinSWalletTest {
@ -83,7 +83,7 @@ class FundTransactionHandlingTest extends BitcoinSWalletTest {
}
}
it must "fail to fund a raw transaction if we have the _exact_ amount of money in the wallet because of the fee" in {fundedWallet: FundedWallet =>
it must "fail to fund a raw transaction if we have the _exact_ amount of money in the wallet because of the fee" in { fundedWallet: FundedWallet =>
//our wallet should only have 6 bitcoin in it
val tooMuchMoney = Bitcoins(6)
val tooBigOutput = destination.copy(value = tooMuchMoney)
@ -100,8 +100,46 @@ class FundTransactionHandlingTest extends BitcoinSWalletTest {
}
}
it must "fund from a specific account" ignore { _: FundedWallet =>
assert(false)
it must "fund from a specific account" in { fundedWallet: FundedWallet =>
//we want to fund from account 1, not hte default account
//account 1 has 1 btc in it
val amt = Bitcoins(0.1)
val newDestination = destination.copy(value = amt)
val wallet = fundedWallet.wallet
val account1 = WalletTestUtil.getHdAccount1(wallet.walletConfig)
val account1DbF = wallet.accountDAO.findByAccount(account1)
for {
account1DbOpt <- account1DbF
fundedTx <- wallet.fundRawTransaction(
Vector(newDestination),
feeRate,
account1DbOpt.get)
} yield {
assert(fundedTx.inputs.nonEmpty)
assert(fundedTx.outputs.contains(newDestination))
assert(fundedTx.outputs.length == 2)
}
}
it must "fail to fund from an account that does not have the funds" in { fundedWallet: FundedWallet =>
//account 1 should only have 1 btc in it
val amt = Bitcoins(1.1)
val newDestination = destination.copy(value = amt)
val wallet = fundedWallet.wallet
val account1 = WalletTestUtil.getHdAccount1(wallet.walletConfig)
val account1DbF = wallet.accountDAO.findByAccount(account1)
val fundedTxF = for {
account1DbOpt <- account1DbF
fundedTx <- wallet.fundRawTransaction(
Vector(newDestination),
feeRate,
account1DbOpt.get)
} yield fundedTx
recoverToSucceededIf[RuntimeException] {
fundedTxF
}
}
}

View File

@ -4,7 +4,7 @@ import java.nio.file.Files
import org.bitcoins.core.crypto.AesPassword
import org.bitcoins.core.hd.HDChainType.{Change, External}
import org.bitcoins.core.hd.{HDChainType, HDPurpose}
import org.bitcoins.core.hd.{HDAccount, HDChainType, HDPurpose}
import org.bitcoins.core.protocol.BitcoinAddress
import org.bitcoins.core.util.FutureUtil
import org.bitcoins.keymanager.KeyManagerUnlockError.MnemonicNotFound
@ -64,16 +64,13 @@ class WalletUnitTest extends BitcoinSWalletTest {
it should "know what the last address index is" in { walletApi =>
val wallet = walletApi.asInstanceOf[Wallet]
def getMostRecent(
purpose: HDPurpose,
chain: HDChainType,
acctIndex: Int
): Future[AddressDb] = {
def getMostRecent(hdAccount: HDAccount,
chain: HDChainType): Future[AddressDb] = {
val recentOptFut: Future[Option[AddressDb]] = chain match {
case Change =>
wallet.addressDAO.findMostRecentChange(purpose, acctIndex)
wallet.addressDAO.findMostRecentChange(hdAccount)
case External =>
wallet.addressDAO.findMostRecentExternal(purpose, acctIndex)
wallet.addressDAO.findMostRecentExternal(hdAccount)
}
recentOptFut.map {
@ -83,11 +80,10 @@ class WalletUnitTest extends BitcoinSWalletTest {
}
def assertIndexIs(
purpose: HDPurpose,
hdAccount: HDAccount,
chain: HDChainType,
addrIndex: Int,
accountIndex: Int): Future[Assertion] = {
getMostRecent(purpose, chain, accountIndex) map { addr =>
addrIndex: Int): Future[Assertion] = {
getMostRecent(hdAccount, chain) map { addr =>
assert(addr.path.address.index == addrIndex)
}
}
@ -100,9 +96,8 @@ class WalletUnitTest extends BitcoinSWalletTest {
* being reported
*/
def testChain(
purpose: HDPurpose,
chain: HDChainType,
accIdx: Int): Future[Assertion] = {
hdAccount: HDAccount,
chain: HDChainType): Future[Assertion] = {
val getAddrFunc: () => Future[BitcoinAddress] = chain match {
case Change => wallet.getNewChangeAddress _
case External => wallet.getNewAddress _
@ -111,9 +106,9 @@ class WalletUnitTest extends BitcoinSWalletTest {
_ <- {
val addrF = chain match {
case Change =>
wallet.addressDAO.findMostRecentChange(purpose, accIdx)
wallet.addressDAO.findMostRecentChange(hdAccount)
case External =>
wallet.addressDAO.findMostRecentExternal(purpose, accIdx)
wallet.addressDAO.findMostRecentExternal(hdAccount)
}
addrF.map {
case Some(addr) =>
@ -123,12 +118,11 @@ class WalletUnitTest extends BitcoinSWalletTest {
}
}
_ <- FutureUtil.sequentially(addrRange)(_ => getAddrFunc())
_ <- assertIndexIs(purpose,
_ <- assertIndexIs(hdAccount,
chain,
accountIndex = accIdx,
addrIndex = addressesToGenerate)
newest <- getAddrFunc()
res <- getMostRecent(purpose, chain, accIdx).map { found =>
res <- getMostRecent(hdAccount, chain).map { found =>
assert(found.address == newest)
assert(found.path.address.index == addressesToGenerate + 1)
}
@ -137,9 +131,8 @@ class WalletUnitTest extends BitcoinSWalletTest {
for {
account <- wallet.getDefaultAccount()
accIdx = account.hdAccount.index
_ <- testChain(wallet.DEFAULT_HD_PURPOSE, External, accIdx)
res <- testChain(wallet.DEFAULT_HD_PURPOSE, Change, accIdx)
_ <- testChain(hdAccount = account.hdAccount, External)
res <- testChain(hdAccount = account.hdAccount, Change)
} yield res
}

View File

@ -11,7 +11,7 @@ class AccountDAOTest extends BitcoinSWalletTest with WalletDAOFixture {
val accountDAO = daos.accountDAO
for {
created <- {
val account = WalletTestUtil.firstAccount
val account = WalletTestUtil.defaultHdAccount
val xpub = CryptoGenerators.extPublicKey.sampleSome
@ -22,4 +22,21 @@ class AccountDAOTest extends BitcoinSWalletTest with WalletDAOFixture {
(created.hdAccount.coin, created.hdAccount.index))
} yield assert(found.contains(created))
}
it must "find an account by HdAccount" in { daos =>
val accountDAO = daos.accountDAO
val account = WalletTestUtil.getHdAccount1(walletAppConfig = walletAppConfig)
for {
created <- {
val xpub = CryptoGenerators.extPublicKey.sampleSome
val accountDb = AccountDb(xpub, account)
accountDAO.create(accountDb)
}
found <- accountDAO.findByAccount(account)
} yield assert(found.contains(created))
}
}

View File

@ -58,10 +58,14 @@ sealed abstract class Wallet extends LockedWallet with UnlockedWalletApi {
}
}
// todo: check if there's addresses in the most recent
// account before creating new
/** Creates a new account my reading from our account database, finding the last account,
* and then incrementing the account index by one, and then creating that account
*
* @param kmParams
* @return
*/
override def createNewAccount(kmParams: KeyManagerParams): Future[Wallet] = {
accountDAO
val lastAccountOptF = accountDAO
.findAll()
.map(_.filter(_.hdAccount.purpose == kmParams.purpose))
.map(_.sortBy(_.hdAccount.index))
@ -69,33 +73,44 @@ sealed abstract class Wallet extends LockedWallet with UnlockedWalletApi {
// to know what the index of our new account
// should be.
.map(_.lastOption)
.flatMap { mostRecentOpt =>
val accountIndex = mostRecentOpt match {
case None => 0 // no accounts present in wallet
case Some(account) => account.hdAccount.index + 1
}
logger.info(
s"Creating new account at index $accountIndex for purpose ${kmParams.purpose}")
val hdCoin =
HDCoin(purpose = keyManager.kmParams.purpose,
coinType = HDUtil.getCoinType(keyManager.kmParams.network))
val newAccount = HDAccount(hdCoin, accountIndex)
val xpub: ExtPublicKey = {
keyManager.deriveXPub(newAccount) match {
case Failure(exception) =>
// this won't happen, because we're deriving from a privkey
// this should really be changed in the method signature
logger.error(s"Unexpected error when deriving xpub: $exception")
throw exception
case Success(xpub) => xpub
}
}
val newAccountDb = AccountDb(xpub, newAccount)
val accountCreationF = accountDAO.create(newAccountDb)
accountCreationF.map(created =>
logger.debug(s"Created new account ${created.hdAccount}"))
accountCreationF
lastAccountOptF.flatMap {
case Some(accountDb) =>
val hdAccount = accountDb.hdAccount
val newAccount = hdAccount.copy(index = hdAccount.index + 1)
createNewAccount(newAccount, kmParams)
case None =>
createNewAccount(walletConfig.defaultAccount, kmParams)
}
}
// todo: check if there's addresses in the most recent
// account before creating new
override def createNewAccount(
hdAccount: HDAccount,
kmParams: KeyManagerParams): Future[Wallet] = {
val accountIndex = hdAccount.index
logger.info(
s"Creating new account at index $accountIndex for purpose ${kmParams.purpose}")
val hdCoin =
HDCoin(purpose = keyManager.kmParams.purpose,
coinType = HDUtil.getCoinType(keyManager.kmParams.network))
val newAccount = HDAccount(hdCoin, accountIndex)
val xpub: ExtPublicKey = {
keyManager.deriveXPub(newAccount) match {
case Failure(exception) =>
// this won't happen, because we're deriving from a privkey
// this should really be changed in the method signature
logger.error(s"Unexpected error when deriving xpub: $exception")
throw exception
case Success(xpub) => xpub
}
}
val newAccountDb = AccountDb(xpub, newAccount)
val accountCreationF = accountDAO.create(newAccountDb)
accountCreationF.map(created =>
logger.debug(s"Created new account ${created.hdAccount}"))
accountCreationF
.map(_ => Wallet(keyManager, nodeApi, chainQueryApi))
}
}

View File

@ -7,10 +7,10 @@ import org.bitcoins.core.config.NetworkParameters
import org.bitcoins.core.crypto.{DoubleSha256DigestBE, _}
import org.bitcoins.core.currency.CurrencyUnit
import org.bitcoins.core.gcs.{GolombFilter, SimpleFilterMatcher}
import org.bitcoins.core.hd.{AddressType, HDPurpose}
import org.bitcoins.core.hd.{AddressType, HDAccount, HDPurpose}
import org.bitcoins.core.protocol.blockchain.{Block, ChainParams}
import org.bitcoins.core.protocol.script.ScriptPubKey
import org.bitcoins.core.protocol.transaction.{Transaction, TransactionOutput}
import org.bitcoins.core.protocol.transaction.Transaction
import org.bitcoins.core.protocol.{BitcoinAddress, BlockStamp}
import org.bitcoins.core.util.FutureUtil
import org.bitcoins.core.wallet.fee.FeeUnit
@ -111,12 +111,28 @@ trait LockedWalletApi extends WalletApi {
} yield confirmed + unconfirmed
}
/** Gets the balance of the given account */
def getBalance(account: HDAccount): Future[CurrencyUnit] = {
val confirmedF = getConfirmedBalance(account)
val unconfirmedF = getUnconfirmedBalance(account)
for {
confirmed <- confirmedF
unconfirmed <- unconfirmedF
} yield {
confirmed + unconfirmed
}
}
/** Gets the sum of all confirmed UTXOs in this wallet */
def getConfirmedBalance(): Future[CurrencyUnit]
def getConfirmedBalance(account: HDAccount): Future[CurrencyUnit]
/** Gets the sum of all unconfirmed UTXOs in this wallet */
def getUnconfirmedBalance(): Future[CurrencyUnit]
def getUnconfirmedBalance(account: HDAccount): Future[CurrencyUnit]
/**
* If a UTXO is spent outside of the wallet, we
* need to remove it from the database so it won't be
@ -129,8 +145,12 @@ trait LockedWalletApi extends WalletApi {
* */
def listUtxos(): Future[Vector[SpendingInfoDb]]
def listUtxos(account: HDAccount): Future[Vector[SpendingInfoDb]]
def listAddresses(): Future[Vector[AddressDb]]
def listAddresses(account: HDAccount): Future[Vector[AddressDb]]
/** Checks if the wallet contains any data */
def isEmpty(): Future[Boolean]
@ -426,6 +446,8 @@ trait UnlockedWalletApi extends LockedWalletApi {
} yield tx
}
def createNewAccount(keyManagerParams: KeyManagerParams): Future[Wallet]
/**
* Tries to create a new account in this wallet. Fails if the
* most recent account has no transaction history, as per
@ -433,7 +455,9 @@ trait UnlockedWalletApi extends LockedWalletApi {
*
* @see [[https://github.com/bitcoin/bips/blob/master/bip-0044.mediawiki#account BIP44 account section]]
*/
def createNewAccount(keyManagerParams: KeyManagerParams): Future[WalletApi]
def createNewAccount(
hdAccount: HDAccount,
keyManagerParams: KeyManagerParams): Future[Wallet]
}

View File

@ -3,7 +3,14 @@ package org.bitcoins.wallet.config
import java.nio.file.{Files, Path}
import com.typesafe.config.Config
import org.bitcoins.core.hd.{AddressType, HDPurpose, HDPurposes}
import org.bitcoins.core.hd.{
AddressType,
HDAccount,
HDCoin,
HDCoinType,
HDPurpose,
HDPurposes
}
import org.bitcoins.db.AppConfig
import org.bitcoins.keymanager.{KeyManagerParams, WalletStorage}
import org.bitcoins.wallet.db.WalletDbManagement
@ -49,6 +56,11 @@ case class WalletAppConfig(
}
}
lazy val defaultAccount: HDAccount = {
val purpose = defaultAccountKind
HDAccount(coin = HDCoin(purpose, HDCoinType.Testnet), index = 0)
}
lazy val bloomFalsePositiveRate: Double =
config.getDouble("wallet.bloomFalsePositiveRate")

View File

@ -33,7 +33,16 @@ private[wallet] trait AccountHandling { self: LockedWallet =>
override protected[wallet] def getDefaultAccount(): Future[AccountDb] = {
for {
account <- accountDAO.read((DEFAULT_HD_COIN, 0))
} yield getOrThrowAccount(account)
} yield {
val acct = getOrThrowAccount(account)
require(
acct.hdAccount == walletConfig.defaultAccount,
s"Divergence between configured default account and " +
s"database default account walletConfig=${walletConfig.defaultAccount} database=${acct.hdAccount}"
)
acct
}
}
/** @inheritdoc */

View File

@ -24,9 +24,39 @@ import scala.util.{Failure, Success}
private[wallet] trait AddressHandling extends WalletLogger {
self: LockedWallet =>
def contains(
address: BitcoinAddress,
accountOpt: Option[HDAccount]): Future[Boolean] = {
val possibleAddressesF = accountOpt match {
case Some(account) =>
listAddresses(account)
case None =>
listAddresses()
}
possibleAddressesF.map { possibleAddresses =>
possibleAddresses.exists(_.address == address)
}
}
override def listAddresses(): Future[Vector[AddressDb]] =
addressDAO.findAll()
override def listAddresses(account: HDAccount): Future[Vector[AddressDb]] = {
val allAddressesF: Future[Vector[AddressDb]] = listAddresses()
val accountAddressesF = {
allAddressesF.map { addresses =>
addresses.filter { a =>
logger.info(s"a.path=${a.path} account=${account}")
HDAccount.isSameAccount(a.path, account)
}
}
}
accountAddressesF
}
/** Enumerates the public keys in this wallet */
protected[wallet] def listPubkeys(): Future[Vector[ECPublicKey]] =
addressDAO.findAllPubkeys()
@ -65,14 +95,11 @@ private[wallet] trait AddressHandling extends WalletLogger {
): Future[BitcoinAddress] = {
logger.debug(s"Getting new $chainType adddress for ${account.hdAccount}")
val accountIndex = account.hdAccount.index
val lastAddrOptF = chainType match {
case HDChainType.External =>
addressDAO.findMostRecentExternal(account.hdAccount.purpose,
accountIndex)
addressDAO.findMostRecentExternal(account.hdAccount)
case HDChainType.Change =>
addressDAO.findMostRecentChange(account.hdAccount.purpose, accountIndex)
addressDAO.findMostRecentChange(account.hdAccount)
}
lastAddrOptF.flatMap { lastAddrOpt =>
@ -129,12 +156,27 @@ private[wallet] trait AddressHandling extends WalletLogger {
}
}
def getNewAddress(account: HDAccount): Future[BitcoinAddress] = {
val accountDbOptF = findAccount(account)
accountDbOptF.flatMap {
case Some(accountDb) => getNewAddress(accountDb)
case None =>
Future.failed(
new RuntimeException(
s"No account found for given hdaccount=${account}"))
}
}
def getNewAddress(account: AccountDb): Future[BitcoinAddress] = {
val addrF =
getNewAddressHelper(account, HDChainType.External)
addrF
}
def findAccount(account: HDAccount): Future[Option[AccountDb]] = {
accountDAO.findByAccount(account)
}
/** @inheritdoc */
override def getNewAddress(
addressType: AddressType): Future[BitcoinAddress] = {

View File

@ -53,11 +53,10 @@ trait FundTransactionHandling extends WalletLogger { self: LockedWalletApi =>
feeRate: FeeUnit,
fromAccount: AccountDb,
keyManagerOpt: Option[BIP39KeyManager]): Future[BitcoinTxBuilder] = {
val utxosF = listUtxos()
val utxosF = listUtxos(fromAccount.hdAccount)
val changeAddrF = getNewChangeAddress(fromAccount)
val selectedUtxosF = for {
walletUtxos <- utxosF
change <- changeAddrF
//currently just grab the biggest utxos
selectedUtxos = CoinSelector
.accumulateLargest(walletUtxos, destinations, feeRate)

View File

@ -2,6 +2,7 @@ package org.bitcoins.wallet.internal
import org.bitcoins.core.compat._
import org.bitcoins.core.crypto.DoubleSha256DigestBE
import org.bitcoins.core.hd.HDAccount
import org.bitcoins.core.number.UInt32
import org.bitcoins.core.protocol.BitcoinAddress
import org.bitcoins.core.protocol.script.ScriptPubKey
@ -29,8 +30,14 @@ private[wallet] trait UtxoHandling extends WalletLogger {
self: LockedWallet =>
/** @inheritdoc */
override def listUtxos(): Future[Vector[SpendingInfoDb]] =
spendingInfoDAO.findAllUnspent()
override def listUtxos(): Future[Vector[SpendingInfoDb]] = {
listUtxos(walletConfig.defaultAccount)
}
override def listUtxos(
hdAccount: HDAccount): Future[Vector[SpendingInfoDb]] = {
spendingInfoDAO.findAllUnspentForAccount(hdAccount)
}
/**
* Tries to convert the provided spk to an address, and then checks if we have

View File

@ -1,13 +1,11 @@
package org.bitcoins.wallet.models
import org.bitcoins.core.hd._
import org.bitcoins.db.{CRUD, SlickUtil}
import org.bitcoins.wallet.config._
import slick.jdbc.SQLiteProfile.api._
import scala.concurrent.{Future}
import org.bitcoins.db.CRUD
import org.bitcoins.db.SlickUtil
import scala.concurrent.ExecutionContext
import scala.concurrent.{ExecutionContext, Future}
case class AccountDAO()(
implicit val ec: ExecutionContext,
@ -38,4 +36,21 @@ case class AccountDAO()(
findByPrimaryKeys(
accounts.map(acc => (acc.hdAccount.coin, acc.hdAccount.index)))
def findByAccount(account: HDAccount): Future[Option[AccountDb]] = {
val q = table
.filter(_.coinType === account.coin.coinType)
.filter(_.purpose === account.purpose)
.filter(_.index === account.index)
database.run(q.result).map {
case h +: Vector() =>
Some(h)
case Vector() =>
None
case accounts: Vector[AccountDb] =>
//yikes, we should not have more the one account per coin type/purpose
throw new RuntimeException(
s"More than one account per account=${account}, got=${accounts}")
}
}
}

View File

@ -1,7 +1,7 @@
package org.bitcoins.wallet.models
import org.bitcoins.core.crypto.ECPublicKey
import org.bitcoins.core.hd.{HDChainType, HDPurpose}
import org.bitcoins.core.hd.{HDAccount, HDChainType}
import org.bitcoins.core.protocol.BitcoinAddress
import org.bitcoins.core.protocol.script.ScriptPubKey
import org.bitcoins.db.{CRUD, SlickUtil}
@ -44,11 +44,9 @@ case class AddressDAO()(
/**
* Finds the most recent change address in the wallet, if any
*/
def findMostRecentChange(
purpose: HDPurpose,
accountIndex: Int): Future[Option[AddressDb]] = {
def findMostRecentChange(hdAccount: HDAccount): Future[Option[AddressDb]] = {
val query =
findMostRecentForChain(purpose, accountIndex, HDChainType.Change)
findMostRecentForChain(hdAccount, HDChainType.Change)
database.run(query)
}
@ -66,11 +64,11 @@ case class AddressDAO()(
}
private def findMostRecentForChain(
purpose: HDPurpose,
accountIndex: Int,
account: HDAccount,
chain: HDChainType): SqlAction[Option[AddressDb], NoStream, Effect.Read] = {
addressesForAccountQuery(accountIndex)
.filter(_.purpose === purpose)
addressesForAccountQuery(account.index)
.filter(_.purpose === account.purpose)
.filter(_.accountCoin === account.coin.coinType)
.filter(_.accountChainType === chain)
.sortBy(_.addressIndex.desc)
.take(1)
@ -82,10 +80,9 @@ case class AddressDAO()(
* Finds the most recent external address in the wallet, if any
*/
def findMostRecentExternal(
purpose: HDPurpose,
accountIndex: Int): Future[Option[AddressDb]] = {
hdAccount: HDAccount): Future[Option[AddressDb]] = {
val query =
findMostRecentForChain(purpose, accountIndex, HDChainType.External)
findMostRecentForChain(hdAccount, HDChainType.External)
database.run(query)
}
}

View File

@ -1,6 +1,7 @@
package org.bitcoins.wallet.models
import org.bitcoins.core.crypto.DoubleSha256DigestBE
import org.bitcoins.core.hd.HDAccount
import org.bitcoins.core.protocol.script.ScriptPubKey
import org.bitcoins.core.protocol.transaction.{
Transaction,
@ -112,10 +113,21 @@ case class SpendingInfoDAO()(
database.run(query.result).map(_.toVector)
}
/** Finds all utxos for a given account */
def findAllUnspentForAccount(
hdAccount: HDAccount): Future[Vector[SpendingInfoDb]] = {
val allUtxosF = findAllUnspent()
allUtxosF.map { allUtxos =>
allUtxos.filter(
utxo =>
HDAccount.isSameAccount(bip32Path = utxo.privKeyPath,
account = hdAccount))
}
}
/** Enumerates all TX outpoints in the wallet */
def findAllOutpoints(): Future[Vector[TransactionOutPoint]] = {
val query = table.map(_.outPoint)
database.runVec(query.result).map(_.toVector)
}
}