diff --git a/app/src/main/resources/explorer-backend-openapi.json b/app/src/main/resources/explorer-backend-openapi.json index 635919e40..3a35da075 100644 --- a/app/src/main/resources/explorer-backend-openapi.json +++ b/app/src/main/resources/explorer-backend-openapi.json @@ -5470,6 +5470,28 @@ "description": "List transactions for given addresses", "operationId": "postAddressesTransactions", "parameters": [ + { + "schema": { + "format": "int64", + "type": "integer", + "minimum": "0" + }, + "in": "query", + "name": "fromTs", + "description": "inclusive", + "required": false + }, + { + "schema": { + "format": "int64", + "type": "integer", + "minimum": "0" + }, + "in": "query", + "name": "toTs", + "description": "exclusive", + "required": false + }, { "schema": { "format": "int32", diff --git a/app/src/main/scala/org/alephium/explorer/api/AddressesEndpoints.scala b/app/src/main/scala/org/alephium/explorer/api/AddressesEndpoints.scala index dca64bbaa..42656f9bc 100644 --- a/app/src/main/scala/org/alephium/explorer/api/AddressesEndpoints.scala +++ b/app/src/main/scala/org/alephium/explorer/api/AddressesEndpoints.scala @@ -31,7 +31,7 @@ import org.alephium.explorer.api.EndpointExamples._ import org.alephium.explorer.api.model._ import org.alephium.protocol.PublicKey import org.alephium.protocol.model.{Address, TokenId} -import org.alephium.util.Duration +import org.alephium.util.{Duration, TimeStamp} // scalastyle:off magic.number trait AddressesEndpoints extends BaseEndpoint with QueryParams { @@ -73,14 +73,16 @@ trait AddressesEndpoints extends BaseEndpoint with QueryParams { .out(jsonBody[ArraySeq[Transaction]]) .description("List transactions of a given address") - lazy val getTransactionsByAddresses - : BaseEndpoint[(ArraySeq[Address], Pagination), ArraySeq[Transaction]] = + // format: off + lazy val getTransactionsByAddresses: BaseEndpoint[(ArraySeq[Address], Option[TimeStamp], Option[TimeStamp], Pagination), ArraySeq[Transaction]] = baseAddressesEndpoint.post .in(arrayBody[Address]("addresses", maxSizeAddresses)) .in("transactions") + .in(optionalTimeIntervalQuery) .in(pagination) .out(jsonBody[ArraySeq[Transaction]]) .description("List transactions for given addresses") + // format: on val getTransactionsByAddressTimeRanged : BaseEndpoint[(Address, TimeInterval, Pagination), ArraySeq[Transaction]] = diff --git a/app/src/main/scala/org/alephium/explorer/api/QueryParams.scala b/app/src/main/scala/org/alephium/explorer/api/QueryParams.scala index 479f164c3..77f7e0c20 100644 --- a/app/src/main/scala/org/alephium/explorer/api/QueryParams.scala +++ b/app/src/main/scala/org/alephium/explorer/api/QueryParams.scala @@ -97,6 +97,17 @@ trait QueryParams extends TapirCodecs { } }) + val optionalTimeIntervalQuery: EndpointInput[(Option[TimeStamp], Option[TimeStamp])] = + query[Option[TimeStamp]]("fromTs") + .description("inclusive") + .and(query[Option[TimeStamp]]("toTs").description("exclusive")) + .validate(Validator.custom { + case (Some(fromTs), Some(toTs)) if fromTs >= toTs => + ValidationResult.Invalid(s"`fromTs` must be before `toTs`") + case _ => + ValidationResult.Valid + }) + val intervalTypeQuery: EndpointInput[IntervalType] = query[IntervalType]("interval-type") diff --git a/app/src/main/scala/org/alephium/explorer/persistence/dao/TransactionDao.scala b/app/src/main/scala/org/alephium/explorer/persistence/dao/TransactionDao.scala index 46dc80b58..c0075d76d 100644 --- a/app/src/main/scala/org/alephium/explorer/persistence/dao/TransactionDao.scala +++ b/app/src/main/scala/org/alephium/explorer/persistence/dao/TransactionDao.scala @@ -42,11 +42,16 @@ object TransactionDao { ): Future[ArraySeq[Transaction]] = run(getTransactionsByAddress(address, pagination)) - def getByAddresses(addresses: ArraySeq[Address], pagination: Pagination)(implicit + def getByAddresses( + addresses: ArraySeq[Address], + fromTime: Option[TimeStamp], + toTime: Option[TimeStamp], + pagination: Pagination + )(implicit ec: ExecutionContext, dc: DatabaseConfig[PostgresProfile] ): Future[ArraySeq[Transaction]] = - run(getTransactionsByAddresses(addresses, pagination)) + run(getTransactionsByAddresses(addresses, fromTime, toTime, pagination)) def getByAddressTimeRanged( address: Address, diff --git a/app/src/main/scala/org/alephium/explorer/persistence/queries/TransactionQueries.scala b/app/src/main/scala/org/alephium/explorer/persistence/queries/TransactionQueries.scala index 08bfdd247..9cef1ff49 100644 --- a/app/src/main/scala/org/alephium/explorer/persistence/queries/TransactionQueries.scala +++ b/app/src/main/scala/org/alephium/explorer/persistence/queries/TransactionQueries.scala @@ -182,11 +182,17 @@ object TransactionQueries extends StrictLogging { * Page number (starting from 0) * @param limit * Maximum rows + * @param fromTs + * From TimeStamp of the time-range (inclusive) + * @param toTs + * To TimeStamp of the time-range (exclusive) * @return * Paginated transactions */ def getTxHashesByAddressesQuery( addresses: ArraySeq[Address], + fromTs: Option[TimeStamp], + toTs: Option[TimeStamp], pagination: Pagination ): DBActionSR[TxByAddressQR] = if (addresses.isEmpty) { @@ -196,10 +202,12 @@ object TransactionQueries extends StrictLogging { val query = s""" - SELECT ${TxByAddressQR.selectFields} + SELECT DISTINCT ${TxByAddressQR.selectFields} FROM transaction_per_addresses WHERE main_chain = true AND address IN $placeholder + ${fromTs.map(ts => s"AND block_timestamp >= ${ts.millis}").getOrElse("")} + ${toTs.map(ts => s"AND block_timestamp < ${ts.millis}").getOrElse("")} ORDER BY block_timestamp DESC, tx_order """ @@ -273,11 +281,16 @@ object TransactionQueries extends StrictLogging { } yield txs } - def getTransactionsByAddresses(addresses: ArraySeq[Address], pagination: Pagination)(implicit + def getTransactionsByAddresses( + addresses: ArraySeq[Address], + fromTime: Option[TimeStamp], + toTime: Option[TimeStamp], + pagination: Pagination + )(implicit ec: ExecutionContext ): DBActionR[ArraySeq[Transaction]] = { for { - txHashesTs <- getTxHashesByAddressesQuery(addresses, pagination) + txHashesTs <- getTxHashesByAddressesQuery(addresses, fromTime, toTime, pagination) txs <- getTransactions(txHashesTs) } yield txs } diff --git a/app/src/main/scala/org/alephium/explorer/service/TransactionService.scala b/app/src/main/scala/org/alephium/explorer/service/TransactionService.scala index ee4409b2b..2cfb996d1 100644 --- a/app/src/main/scala/org/alephium/explorer/service/TransactionService.scala +++ b/app/src/main/scala/org/alephium/explorer/service/TransactionService.scala @@ -68,7 +68,12 @@ trait TransactionService { dc: DatabaseConfig[PostgresProfile] ): Future[Option[TransactionInfo]] - def getTransactionsByAddresses(addresses: ArraySeq[Address], pagination: Pagination)(implicit + def getTransactionsByAddresses( + addresses: ArraySeq[Address], + fromTime: Option[TimeStamp], + toTime: Option[TimeStamp], + pagination: Pagination + )(implicit ec: ExecutionContext, dc: DatabaseConfig[PostgresProfile] ): Future[ArraySeq[Transaction]] @@ -168,11 +173,16 @@ object TransactionService extends TransactionService { ): Future[Option[TransactionInfo]] = TransactionDao.getLatestTransactionInfoByAddress(address) - def getTransactionsByAddresses(addresses: ArraySeq[Address], pagination: Pagination)(implicit + def getTransactionsByAddresses( + addresses: ArraySeq[Address], + fromTime: Option[TimeStamp], + toTime: Option[TimeStamp], + pagination: Pagination + )(implicit ec: ExecutionContext, dc: DatabaseConfig[PostgresProfile] ): Future[ArraySeq[Transaction]] = - TransactionDao.getByAddresses(addresses, pagination) + TransactionDao.getByAddresses(addresses, fromTime, toTime, pagination) def listMempoolTransactionsByAddress(address: Address)(implicit ec: ExecutionContext, diff --git a/app/src/main/scala/org/alephium/explorer/web/AddressServer.scala b/app/src/main/scala/org/alephium/explorer/web/AddressServer.scala index 6978cedf3..951da4f6a 100644 --- a/app/src/main/scala/org/alephium/explorer/web/AddressServer.scala +++ b/app/src/main/scala/org/alephium/explorer/web/AddressServer.scala @@ -63,9 +63,10 @@ class AddressServer( transactionService .getTransactionsByAddress(address, pagination) }), - route(getTransactionsByAddresses.serverLogicSuccess[Future] { case (addresses, pagination) => - transactionService - .getTransactionsByAddresses(addresses, pagination) + route(getTransactionsByAddresses.serverLogicSuccess[Future] { + case (addresses, fromTsOpt, toTsOpt, pagination) => + transactionService + .getTransactionsByAddresses(addresses, fromTsOpt, toTsOpt, pagination) }), route(getTransactionsByAddressTimeRanged.serverLogicSuccess[Future] { case (address, timeInterval, pagination) => diff --git a/app/src/test/scala/org/alephium/explorer/persistence/queries/TransactionQueriesSpec.scala b/app/src/test/scala/org/alephium/explorer/persistence/queries/TransactionQueriesSpec.scala index 04906b5f9..dd86d2091 100644 --- a/app/src/test/scala/org/alephium/explorer/persistence/queries/TransactionQueriesSpec.scala +++ b/app/src/test/scala/org/alephium/explorer/persistence/queries/TransactionQueriesSpec.scala @@ -508,6 +508,8 @@ class TransactionQueriesSpec extends AlephiumFutureSpec with DatabaseFixtureForE val query = TransactionQueries.getTxHashesByAddressesQuery( addresses, + None, + None, Pagination.unsafe(page, limit) ) @@ -536,6 +538,8 @@ class TransactionQueriesSpec extends AlephiumFutureSpec with DatabaseFixtureForE val query = TransactionQueries.getTxHashesByAddressesQuery( shuffledAddresses, + None, + None, Pagination.unsafe(1, Int.MaxValue) ) @@ -555,6 +559,112 @@ class TransactionQueriesSpec extends AlephiumFutureSpec with DatabaseFixtureForE } } } + + "return distinct transactions" when { + "a transaction is associated with multiple addresses" in { + forAll(Gen.listOf(genTransactionPerAddressEntity(mainChain = Gen.const(true)))) { + entities => + val toPersist = entities.flatMap { entity => + Seq(entity, entity.copy(address = addressGen.sample.get)) + } + + // clear table and insert entities + run(TransactionPerAddressSchema.table.delete).futureValue + run(TransactionPerAddressSchema.table ++= toPersist).futureValue + + val query = + TransactionQueries.getTxHashesByAddressesQuery( + toPersist.map(_.address), + None, + None, + Pagination.unsafe(1, Int.MaxValue) + ) + + val expectedResult = + entities map { entity => + TxByAddressQR( + entity.hash, + entity.blockHash, + entity.timestamp, + entity.txOrder, + entity.coinbase + ) + } + + run(query).futureValue should contain theSameElementsAs expectedResult + } + } + } + + "return timed range transactions" in new Fixture { + forAll( + Gen.nonEmptyListOf( + genTransactionPerAddressEntity( + addressGen = Gen.const(address), + mainChain = Gen.const(true) + ) + ) + ) { entities => + run(TransactionPerAddressSchema.table.delete).futureValue + run(TransactionPerAddressSchema.table ++= entities).futureValue + + val timestamps = entities.map(_.timestamp).distinct + val max = timestamps.max + val min = timestamps.min + + def query(fromTs: Option[TimeStamp], toTs: Option[TimeStamp]) = { + run( + TransactionQueries.getTxHashesByAddressesQuery( + Seq(address), + fromTs, + toTs, + Pagination.unsafe(1, Int.MaxValue) + ) + ).futureValue + } + + def expected(entities: Seq[TransactionPerAddressEntity]) = { + entities.map { entity => + TxByAddressQR( + entity.hash, + entity.blockHash, + entity.timestamp, + entity.txOrder, + entity.coinbase + ) + } + } + + def test( + fromTs: Option[TimeStamp], + toTs: Option[TimeStamp], + expectedEntites: Seq[TxByAddressQR] + ) = { + query(fromTs, toTs) should contain theSameElementsAs expectedEntites + } + + // fromTs is inclusive + test(fromTs = Some(max), toTs = None, expected(Seq(entities.maxBy(_.timestamp)))) + + // toTs is exclusive + test(fromTs = None, toTs = Some(max), expected(entities.sortBy(_.timestamp).init)) + + // Verifying max+1 include the last element + test( + fromTs = None, + toTs = Some(max.plusMillisUnsafe(1)), + expected(entities.sortBy(_.timestamp)) + ) + + // excluding min and max elememt + test( + fromTs = Some(min.plusMillisUnsafe(1)), + toTs = Some(max), + expected(entities.sortBy(_.timestamp).init.drop(1)) + ) + + } + } } trait Fixture { diff --git a/app/src/test/scala/org/alephium/explorer/service/EmptyTransactionService.scala b/app/src/test/scala/org/alephium/explorer/service/EmptyTransactionService.scala index 2c9899a60..a1437600b 100644 --- a/app/src/test/scala/org/alephium/explorer/service/EmptyTransactionService.scala +++ b/app/src/test/scala/org/alephium/explorer/service/EmptyTransactionService.scala @@ -51,8 +51,12 @@ trait EmptyTransactionService extends TransactionService { ): Future[ArraySeq[Transaction]] = Future.successful(ArraySeq.empty) - override def getTransactionsByAddresses(addresses: ArraySeq[Address], pagination: Pagination)( - implicit + override def getTransactionsByAddresses( + addresses: ArraySeq[Address], + fromTs: Option[TimeStamp], + toTs: Option[TimeStamp], + pagination: Pagination + )(implicit ec: ExecutionContext, dc: DatabaseConfig[PostgresProfile] ): Future[ArraySeq[Transaction]] =