Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/main/scala/org/ergoplatform/http/api/WalletApiRoute.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -397,18 +406,31 @@ 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)
} else {
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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 =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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

Expand Down
9 changes: 7 additions & 2 deletions src/test/scala/org/ergoplatform/utils/Stubs.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading