diff --git a/src/main/scala/org/ergoplatform/http/api/WalletApiRoute.scala b/src/main/scala/org/ergoplatform/http/api/WalletApiRoute.scala index 1232e882fd..ea24a8e179 100644 --- a/src/main/scala/org/ergoplatform/http/api/WalletApiRoute.scala +++ b/src/main/scala/org/ergoplatform/http/api/WalletApiRoute.scala @@ -308,7 +308,7 @@ case class WalletApiRoute(readersHolder: ActorRef, (minConfNum, maxConfNum, minHeight, maxHeight, limit, offset) => val considerUnconfirmed = minConfNum == -1 withWallet { wallet => - wallet.walletBoxes(unspentOnly = true, considerUnconfirmed) + wallet.walletBoxes(unspentOnly = true, considerUnconfirmed, minHeight, maxHeight) .map { boxes => boxes .filter(boxConfirmationHeightFilter(_, minConfNum, maxConfNum, minHeight, maxHeight)) @@ -321,7 +321,7 @@ case class WalletApiRoute(readersHolder: ActorRef, (minConfNum, maxConfNum, minHeight, maxHeight, limit, offset) => val considerUnconfirmed = minConfNum == -1 withWallet { - _.walletBoxes(unspentOnly = false, considerUnconfirmed = considerUnconfirmed) + _.walletBoxes(unspentOnly = false, considerUnconfirmed = considerUnconfirmed, minHeight, maxHeight) .map { _.filter(boxConfirmationHeightFilter(_, minConfNum, maxConfNum, minHeight, maxHeight)) .slice(offset, offset + limit) diff --git a/src/main/scala/org/ergoplatform/nodeView/wallet/ErgoWalletActor.scala b/src/main/scala/org/ergoplatform/nodeView/wallet/ErgoWalletActor.scala index 78d7621a25..dc0aea4be2 100644 --- a/src/main/scala/org/ergoplatform/nodeView/wallet/ErgoWalletActor.scala +++ b/src/main/scala/org/ergoplatform/nodeView/wallet/ErgoWalletActor.scala @@ -174,8 +174,8 @@ class ErgoWalletActor(settings: ErgoSettings, * Read wallet boxes, unspent only (if corresponding flag is set), or all (both spent and unspent). * If considerUnconfirmed flag is set, mempool contents is considered as well. */ - case GetWalletBoxes(unspent, considerUnconfirmed) => - val boxes = ergoWalletService.getWalletBoxes(state, unspent, considerUnconfirmed) + case GetWalletBoxes(unspent, considerUnconfirmed, minHeight, maxHeight) => + val boxes = ergoWalletService.getWalletBoxes(state, unspent, considerUnconfirmed, minHeight, maxHeight) sender() ! boxes case GetScanUnspentBoxes(scanId, considerUnconfirmed, minHeight, maxHeight) => diff --git a/src/main/scala/org/ergoplatform/nodeView/wallet/ErgoWalletActorMessages.scala b/src/main/scala/org/ergoplatform/nodeView/wallet/ErgoWalletActorMessages.scala index a5b3d470fe..3508491d98 100644 --- a/src/main/scala/org/ergoplatform/nodeView/wallet/ErgoWalletActorMessages.scala +++ b/src/main/scala/org/ergoplatform/nodeView/wallet/ErgoWalletActorMessages.scala @@ -172,8 +172,11 @@ object ErgoWalletActorMessages { * @param unspentOnly - return only unspent boxes * @param considerUnconfirmed - consider mempool (filter our unspent boxes spent in the pool if unspent = true, add * boxes created in the pool for both values of unspentOnly). + * @param minHeight - min inclusion height of boxes to read from the database + * @param maxHeight - max inclusion height of boxes to read from the database, -1 for unbounded */ - final case class GetWalletBoxes(unspentOnly: Boolean, considerUnconfirmed: Boolean) + final case class GetWalletBoxes(unspentOnly: Boolean, considerUnconfirmed: Boolean, + minHeight: Int = 0, maxHeight: Int = -1) /** * Get boxes by requested params diff --git a/src/main/scala/org/ergoplatform/nodeView/wallet/ErgoWalletReader.scala b/src/main/scala/org/ergoplatform/nodeView/wallet/ErgoWalletReader.scala index c566a6fd15..8994c15e94 100644 --- a/src/main/scala/org/ergoplatform/nodeView/wallet/ErgoWalletReader.scala +++ b/src/main/scala/org/ergoplatform/nodeView/wallet/ErgoWalletReader.scala @@ -79,8 +79,9 @@ trait ErgoWalletReader extends NodeViewComponent { def getPrivateKeyFromPath(path: DerivationPath): Future[Try[DLogProverInput]] = (walletActor ? GetPrivateKeyFromPath(path)).mapTo[Try[DLogProverInput]] - def walletBoxes(unspentOnly: Boolean, considerUnconfirmed: Boolean): Future[Seq[WalletBox]] = - (walletActor ? GetWalletBoxes(unspentOnly, considerUnconfirmed)).mapTo[Seq[WalletBox]] + def walletBoxes(unspentOnly: Boolean, considerUnconfirmed: Boolean, + minHeight: Int = 0, maxHeight: Int = -1): Future[Seq[WalletBox]] = + (walletActor ? GetWalletBoxes(unspentOnly, considerUnconfirmed, minHeight, maxHeight)).mapTo[Seq[WalletBox]] def scanUnspentBoxes(scanId: ScanId, considerUnconfirmed: Boolean, minHeight: Int, maxHeight: Int): Future[Seq[WalletBox]] = (walletActor ? GetScanUnspentBoxes(scanId, considerUnconfirmed, minHeight, maxHeight)).mapTo[Seq[WalletBox]] diff --git a/src/main/scala/org/ergoplatform/nodeView/wallet/ErgoWalletService.scala b/src/main/scala/org/ergoplatform/nodeView/wallet/ErgoWalletService.scala index 8d4fbc341a..08b88813a3 100644 --- a/src/main/scala/org/ergoplatform/nodeView/wallet/ErgoWalletService.scala +++ b/src/main/scala/org/ergoplatform/nodeView/wallet/ErgoWalletService.scala @@ -111,7 +111,16 @@ trait ErgoWalletService { */ def recreateStorage(state: ErgoWalletState, settings: ErgoSettings): Try[ErgoWalletState] - def getWalletBoxes(state: ErgoWalletState, unspentOnly: Boolean, considerUnconfirmed: Boolean): Seq[WalletBox] + /** + * @param state current wallet state + * @param unspentOnly return only unspent boxes + * @param considerUnconfirmed whether to look for boxes in off-chain registry + * @param minHeight min inclusion height of boxes to read from the database + * @param maxHeight max inclusion height of boxes to read from the database, -1 for unbounded + * @return wallet (P2PK-payments) boxes + */ + def getWalletBoxes(state: ErgoWalletState, unspentOnly: Boolean, considerUnconfirmed: Boolean, + minHeight: Int = 0, maxHeight: Int = -1): Seq[WalletBox] /** * @param state current wallet state @@ -397,10 +406,19 @@ class ErgoWalletServiceImpl(override val ergoSettings: ErgoSettings) extends Erg state.copy(storage = WalletStorage.readOrCreate(settings)) } - override def getWalletBoxes(state: ErgoWalletState, unspentOnly: Boolean, considerUnconfirmed: Boolean): Seq[WalletBox] = { + override def getWalletBoxes(state: ErgoWalletState, unspentOnly: Boolean, considerUnconfirmed: Boolean, + minHeight: Int, maxHeight: Int): Seq[WalletBox] = { val currentHeight = state.fullHeight + // When an inclusion height range is requested we read it from the inclusion-height index, so that only + // the requested window is fetched from the database. Without a range we keep reading the unspent index + // as before, capped by the same limit, to avoid loading the whole box space of a large wallet. + val heightRangeRequested = minHeight > 0 || maxHeight >= 0 val boxes = if (unspentOnly) { - val confirmed = state.registry.walletUnspentBoxes(state.maxInputsToUse * BoxSelector.ScanDepthFactor) + val confirmed = if (heightRangeRequested) { + state.registry.walletUnspentBoxesByInclusionHeight(minHeight, maxHeight) + } else { + state.registry.walletUnspentBoxes(state.maxInputsToUse * BoxSelector.ScanDepthFactor) + } if (considerUnconfirmed) { // We filter out spent boxes in the same way as wallet does when assembling a transaction (confirmed ++ state.offChainRegistry.offChainBoxes).filter(state.walletFilter) @@ -408,7 +426,11 @@ class ErgoWalletServiceImpl(override val ergoSettings: ErgoSettings) extends Erg confirmed } } else { - val confirmed = state.registry.walletConfirmedBoxes() + val confirmed = if (heightRangeRequested) { + state.registry.walletBoxesByInclusionHeight(minHeight, maxHeight) + } else { + state.registry.walletConfirmedBoxes() + } if (considerUnconfirmed) { // Just adding boxes created off-chain confirmed ++ state.offChainRegistry.offChainBoxes diff --git a/src/main/scala/org/ergoplatform/nodeView/wallet/persistence/WalletRegistry.scala b/src/main/scala/org/ergoplatform/nodeView/wallet/persistence/WalletRegistry.scala index f3e7deba48..361be1cd16 100644 --- a/src/main/scala/org/ergoplatform/nodeView/wallet/persistence/WalletRegistry.scala +++ b/src/main/scala/org/ergoplatform/nodeView/wallet/persistence/WalletRegistry.scala @@ -159,6 +159,29 @@ class WalletRegistry(private val store: LDBVersionedStore)(ws: WalletSettings) e */ def walletUnspentBoxes(limit: Int = Int.MaxValue): Seq[TrackedBox] = unspentBoxes(Constants.PaymentsScanId, limit) + /** + * Unspent boxes belong to the wallet (payments scan) within an inclusion height range. + * + * Unlike [[walletUnspentBoxes]], this reads from the inclusion-height index, so only the + * requested height window is fetched from the database and no limit-based truncation is applied. + * + * @param heightFrom - min inclusion height of boxes + * @param heightTo - max inclusion height of boxes, -1 for unbounded + * @return sequence of (P2PK-payment)-related unspent boxes within the range + */ + def walletUnspentBoxesByInclusionHeight(heightFrom: Height, heightTo: Height): Seq[TrackedBox] = + unspentBoxesByInclusionHeight(Constants.PaymentsScanId, heightFrom, heightTo) + + /** + * Boxes belong to the wallet (payments scan) within an inclusion height range, both spent and unspent. + * + * @param heightFrom - min inclusion height of boxes + * @param heightTo - max inclusion height of boxes, -1 for unbounded + * @return sequence of (P2PK-payment)-related boxes within the range + */ + def walletBoxesByInclusionHeight(heightFrom: Height, heightTo: Height): Seq[TrackedBox] = + boxesByInclusionHeight(Constants.PaymentsScanId, heightFrom, heightTo) + /** * Spent boxes belong to the wallet (payments scan) */ diff --git a/src/test/scala/org/ergoplatform/nodeView/wallet/ErgoWalletServiceSpec.scala b/src/test/scala/org/ergoplatform/nodeView/wallet/ErgoWalletServiceSpec.scala index cc261eebc5..a2a7af9601 100644 --- a/src/test/scala/org/ergoplatform/nodeView/wallet/ErgoWalletServiceSpec.scala +++ b/src/test/scala/org/ergoplatform/nodeView/wallet/ErgoWalletServiceSpec.scala @@ -56,6 +56,9 @@ class ErgoWalletServiceSpec override def afterAll(): Unit = try super.afterAll() finally x.stop() + // box ids are Array[Byte], which compares by reference, so encode them before putting them into sets + private def idOf(tb: TrackedBox): String = Base16.encode(tb.box.id) + private def initialState(store: LDBKVStore, versionedStore: LDBVersionedStore, mempool: Option[ErgoMemPoolReader] = None) = { ErgoWalletState( new WalletStorage(store, settings), @@ -243,6 +246,65 @@ class ErgoWalletServiceSpec } } + property("it should read wallet boxes within an inclusion height range only") { + // `copy` does not change box.id, so distinct boxes are needed to tell the two windows apart + val distinctBoxesGen = Gen.listOfN(6, trackedBoxGen) + .map(_.groupBy(tb => Base16.encode(tb.box.id)).values.map(_.head).toList) + .suchThat(_.size >= 3) + + forAll(distinctBoxesGen, modifierIdGen) { case (boxes, txId) => + withVersionedStore(10) { versionedStore => + withStore { store => + val wState = initialState(store, versionedStore) + val blockId = modifierIdGen.sample.get + + // two disjoint height windows, so that a range query can be told apart from a full scan + val (lowSource, highSource) = boxes.splitAt(boxes.size / 2) + val low = lowSource.zipWithIndex.map { case (bx, i) => + bx.copy(spendingHeightOpt = None, spendingTxIdOpt = None, scans = Set(PaymentsScanId), + inclusionHeightOpt = Some(10 + i)) + } + val high = highSource.tail.zipWithIndex.map { case (bx, i) => + bx.copy(spendingHeightOpt = None, spendingTxIdOpt = None, scans = Set(PaymentsScanId), + inclusionHeightOpt = Some(1000 + i)) + } + // a spent box inside the low window, to check it is excluded when unspentOnly is set + val spentLow = highSource.head.copy(spendingHeightOpt = Some(10000), spendingTxIdOpt = Some(txId), + scans = Set(PaymentsScanId), inclusionHeightOpt = Some(11)) + val allBoxes = (low ++ high) :+ spentLow + wState.registry.updateOnBlock(ScanResults(allBoxes, ArraySeq.empty, ArraySeq.empty), blockId, 2000).get + + val walletService = new ErgoWalletServiceImpl(settings) + + def unspentIn(minHeight: Int, maxHeight: Int) = + walletService.getWalletBoxes(wState, unspentOnly = true, considerUnconfirmed = false, minHeight, maxHeight) + .map(bx => idOf(bx.trackedBox)).toSet + + val lowIds = low.map(idOf).toSet + val highIds = high.map(idOf).toSet + + // the low window must not leak boxes from the high window and vice versa + unspentIn(0, 999) shouldBe lowIds + unspentIn(1000, 2000) shouldBe highIds + // spent boxes stay excluded when unspentOnly is set, even inside the window + unspentIn(0, 999) should not contain idOf(spentLow) + // an empty window yields nothing + unspentIn(500, 600) shouldBe empty + + // -1 as upper bound keeps the pre-existing "unbounded" behaviour + val unbounded = walletService + .getWalletBoxes(wState, unspentOnly = true, considerUnconfirmed = false) + .map(bx => idOf(bx.trackedBox)).toSet + unspentIn(0, -1) shouldBe unbounded + + // both spent and unspent within the window when unspentOnly is not set + walletService.getWalletBoxes(wState, unspentOnly = false, considerUnconfirmed = false, 0, 999) + .map(bx => idOf(bx.trackedBox)).toSet shouldBe (lowIds + idOf(spentLow)) + } + } + } + } + property("it should generate signed and unsigned transaction") { withVersionedStore(2) { versionedStore => withStore { store => diff --git a/src/test/scala/org/ergoplatform/nodeView/wallet/persistence/WalletRegistrySpec.scala b/src/test/scala/org/ergoplatform/nodeView/wallet/persistence/WalletRegistrySpec.scala index 03ec0aa24b..6817733e8e 100644 --- a/src/test/scala/org/ergoplatform/nodeView/wallet/persistence/WalletRegistrySpec.scala +++ b/src/test/scala/org/ergoplatform/nodeView/wallet/persistence/WalletRegistrySpec.scala @@ -30,6 +30,9 @@ class WalletRegistrySpec private val emptyBag = KeyValuePairsBag.empty private val walletBoxStatus = Set(PaymentsScanId) + // box ids are Array[Byte], which compares by reference, so encode them before putting them into sets + private def idOf(tb: TrackedBox): String = Base16.encode(tb.box.id) + private val ws = settings.walletSettings it should "read unspent wallet boxes" in { @@ -291,6 +294,45 @@ class WalletRegistrySpec } } + it should "get wallet boxes by inclusion height" in { + forAll(trackedBoxGen) { tb0 => + withVersionedStore(10) { store => + val reg = new WalletRegistry(store)(ws) + + val unspentLow = tb0.copy(scans = Set(PaymentsScanId), inclusionHeightOpt = Some(5), spendingHeightOpt = None) + val unspentHigh = trackedBoxGen.sample.get + .copy(scans = Set(PaymentsScanId), inclusionHeightOpt = Some(50), spendingHeightOpt = None) + val spentMid = trackedBoxGen.sample.get + .copy(scans = Set(PaymentsScanId), inclusionHeightOpt = Some(20), spendingHeightOpt = Some(25)) + Seq(unspentLow, unspentHigh, spentMid).foreach { bx => + WalletRegistry.putBox(emptyBag, bx).transact(store).get + } + + // range covering everything, both spent and unspent + reg.walletBoxesByInclusionHeight(0, 100).map(idOf).toSet shouldBe + Set(idOf(unspentLow), idOf(unspentHigh), idOf(spentMid)) + // only unspent ones + reg.walletUnspentBoxesByInclusionHeight(0, 100).map(idOf).toSet shouldBe + Set(idOf(unspentLow), idOf(unspentHigh)) + + // bounds are inclusive on both ends + reg.walletUnspentBoxesByInclusionHeight(5, 5).map(idOf).toSet shouldBe Set(idOf(unspentLow)) + reg.walletUnspentBoxesByInclusionHeight(50, 50).map(idOf).toSet shouldBe Set(idOf(unspentHigh)) + // a window containing no box yields nothing + reg.walletUnspentBoxesByInclusionHeight(6, 49) shouldBe empty + // lower bound only + reg.walletUnspentBoxesByInclusionHeight(6, 100).map(idOf).toSet shouldBe Set(idOf(unspentHigh)) + + // -1 as upper bound means "unbounded", same convention as /scan/unspentBoxes; + // it must return exactly what the unspent index returns + reg.walletUnspentBoxesByInclusionHeight(0, -1).map(idOf).toSet shouldBe + reg.walletUnspentBoxes().map(idOf).toSet + reg.walletBoxesByInclusionHeight(0, -1).map(idOf).toSet shouldBe + reg.walletConfirmedBoxes().map(idOf).toSet + } + } + } + it should "remove application from a box correctly" in { val appId: ScanId = ScanId @@ 20.toShort diff --git a/src/test/scala/org/ergoplatform/utils/Stubs.scala b/src/test/scala/org/ergoplatform/utils/Stubs.scala index 85a34c17c7..77283a6b94 100644 --- a/src/test/scala/org/ergoplatform/utils/Stubs.scala +++ b/src/test/scala/org/ergoplatform/utils/Stubs.scala @@ -195,13 +195,18 @@ trait Stubs extends ErgoTestHelpers with TestFileUtils { case _: CheckSeed => sender() ! true - case GetWalletBoxes(unspentOnly, _) => + case GetWalletBoxes(unspentOnly, _, minHeight, maxHeight) => val boxes = if (unspentOnly) { Seq(walletBox10_10, walletBox20_30) } else { Seq(walletBox10_10, walletBox20_30, walletBoxSpent21_31) } - sender() ! boxes.sortBy(_.trackedBox.inclusionHeightOpt) + // emulate the inclusion-height range read done by the registry + val inRange = boxes.filter { bx => + val h = bx.trackedBox.inclusionHeightOpt.getOrElse(0) + h >= minHeight && (maxHeight < 0 || h <= maxHeight) + } + sender() ! inRange.sortBy(_.trackedBox.inclusionHeightOpt) case GetScanTransactions(scanId, includeUnconfirmed) => if (includeUnconfirmed) {