From df373721802e2f4701996cdf63e7e9e1124e2acd Mon Sep 17 00:00:00 2001 From: kennycud Date: Tue, 11 Feb 2025 18:45:57 -0800 Subject: [PATCH] trade ledger export implementation, completed trades bug fix --- .../api/model/CrossChainTradeLedgerEntry.java | 72 +++++++++ .../api/resource/CrossChainResource.java | 141 +++++++++++++++++- .../qortal/api/resource/CrossChainUtils.java | 139 +++++++++++++++++ .../java/org/qortal/crosschain/Bitcoiny.java | 1 + .../qortal/crosschain/ForeignBlockchain.java | 2 + .../org/qortal/repository/ATRepository.java | 6 +- .../repository/hsqldb/HSQLDBATRepository.java | 29 ++-- .../qortal/test/api/CrossChainUtilsTests.java | 54 +++++++ 8 files changed, 422 insertions(+), 22 deletions(-) create mode 100644 src/main/java/org/qortal/api/model/CrossChainTradeLedgerEntry.java diff --git a/src/main/java/org/qortal/api/model/CrossChainTradeLedgerEntry.java b/src/main/java/org/qortal/api/model/CrossChainTradeLedgerEntry.java new file mode 100644 index 00000000..34f8fc57 --- /dev/null +++ b/src/main/java/org/qortal/api/model/CrossChainTradeLedgerEntry.java @@ -0,0 +1,72 @@ +package org.qortal.api.model; + +import io.swagger.v3.oas.annotations.media.Schema; +import org.qortal.data.crosschain.CrossChainTradeData; + +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; +import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter; + +// All properties to be converted to JSON via JAXB +@XmlAccessorType(XmlAccessType.FIELD) +public class CrossChainTradeLedgerEntry { + + private String market; + + private String currency; + + @XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class) + private long quantity; + + @XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class) + private long feeAmount; + + private String feeCurrency; + + @XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class) + private long totalPrice; + + private long tradeTimestamp; + + protected CrossChainTradeLedgerEntry() { + /* For JAXB */ + } + + public CrossChainTradeLedgerEntry(String market, String currency, long quantity, long feeAmount, String feeCurrency, long totalPrice, long tradeTimestamp) { + this.market = market; + this.currency = currency; + this.quantity = quantity; + this.feeAmount = feeAmount; + this.feeCurrency = feeCurrency; + this.totalPrice = totalPrice; + this.tradeTimestamp = tradeTimestamp; + } + + public String getMarket() { + return market; + } + + public String getCurrency() { + return currency; + } + + public long getQuantity() { + return quantity; + } + + public long getFeeAmount() { + return feeAmount; + } + + public String getFeeCurrency() { + return feeCurrency; + } + + public long getTotalPrice() { + return totalPrice; + } + + public long getTradeTimestamp() { + return tradeTimestamp; + } +} \ No newline at end of file diff --git a/src/main/java/org/qortal/api/resource/CrossChainResource.java b/src/main/java/org/qortal/api/resource/CrossChainResource.java index 748dcbe4..3f7acf68 100644 --- a/src/main/java/org/qortal/api/resource/CrossChainResource.java +++ b/src/main/java/org/qortal/api/resource/CrossChainResource.java @@ -10,11 +10,13 @@ import io.swagger.v3.oas.annotations.parameters.RequestBody; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; +import org.glassfish.jersey.media.multipart.ContentDisposition; import org.qortal.api.ApiError; import org.qortal.api.ApiErrors; import org.qortal.api.ApiExceptionFactory; import org.qortal.api.Security; import org.qortal.api.model.CrossChainCancelRequest; +import org.qortal.api.model.CrossChainTradeLedgerEntry; import org.qortal.api.model.CrossChainTradeSummary; import org.qortal.controller.tradebot.TradeBot; import org.qortal.crosschain.ACCT; @@ -44,10 +46,14 @@ import org.qortal.utils.Base58; import org.qortal.utils.ByteArray; import org.qortal.utils.NTP; +import javax.servlet.ServletContext; import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; import javax.ws.rs.*; import javax.ws.rs.core.Context; +import javax.ws.rs.core.HttpHeaders; import javax.ws.rs.core.MediaType; +import java.io.IOException; import java.util.*; import java.util.function.Supplier; import java.util.stream.Collectors; @@ -61,6 +67,13 @@ public class CrossChainResource { @Context HttpServletRequest request; + @Context + HttpServletResponse response; + + @Context + ServletContext context; + + @GET @Path("/tradeoffers") @Operation( @@ -258,11 +271,11 @@ public class CrossChainResource { example = "1597310000000" ) @QueryParam("minimumTimestamp") Long minimumTimestamp, @Parameter( - description = "Optionally filter by buyer Qortal address" - ) @QueryParam("buyerAddress") String buyerAddress, + description = "Optionally filter by buyer Qortal public key" + ) @QueryParam("buyerPublicKey") String buyerPublicKey58, @Parameter( - description = "Optionally filter by seller Qortal address" - ) @QueryParam("sellerAddress") String sellerAddress, + description = "Optionally filter by seller Qortal public key" + ) @QueryParam("sellerPublicKey") String sellerPublicKey58, @Parameter( ref = "limit") @QueryParam("limit") Integer limit, @Parameter( ref = "offset" ) @QueryParam("offset") Integer offset, @Parameter( ref = "reverse" ) @QueryParam("reverse") Boolean reverse) { @@ -274,6 +287,10 @@ public class CrossChainResource { if (minimumTimestamp != null && minimumTimestamp <= 0) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + // Decode public keys + byte[] buyerPublicKey = decodePublicKey(buyerPublicKey58); + byte[] sellerPublicKey = decodePublicKey(sellerPublicKey58); + final Boolean isFinished = Boolean.TRUE; try (final Repository repository = RepositoryManager.getRepository()) { @@ -304,7 +321,7 @@ public class CrossChainResource { byte[] codeHash = acctInfo.getKey().value; ACCT acct = acctInfo.getValue().get(); - List atStates = repository.getATRepository().getMatchingFinalATStates(codeHash, buyerAddress, sellerAddress, + List atStates = repository.getATRepository().getMatchingFinalATStates(codeHash, buyerPublicKey, sellerPublicKey, isFinished, acct.getModeByteOffset(), (long) AcctMode.REDEEMED.value, minimumFinalHeight, limit, offset, reverse); @@ -343,6 +360,120 @@ public class CrossChainResource { } } + /** + * Decode Public Key + * + * @param publicKey58 the public key in a string + * + * @return the public key in bytes + */ + private byte[] decodePublicKey(String publicKey58) { + + if( publicKey58 == null ) return null; + if( publicKey58.isEmpty() ) return new byte[0]; + + byte[] publicKey; + try { + publicKey = Base58.decode(publicKey58); + } catch (NumberFormatException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY, e); + } + + // Correct size for public key? + if (publicKey.length != Transformer.PUBLIC_KEY_LENGTH) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY); + + return publicKey; + } + + @GET + @Path("/ledger/{publicKey}") + @Operation( + summary = "Accounting entries for all trades.", + description = "Returns accounting entries for all completed cross-chain trades", + responses = { + @ApiResponse( + content = @Content( + schema = @Schema( + type = "string", + format = "byte" + ) + ) + ) + } + ) + @ApiErrors({ApiError.INVALID_CRITERIA, ApiError.REPOSITORY_ISSUE}) + public HttpServletResponse getLedgerEntries( + @PathParam("publicKey") String publicKey58, + @Parameter( + description = "Only return trades that completed on/after this timestamp (milliseconds since epoch)", + example = "1597310000000" + ) @QueryParam("minimumTimestamp") Long minimumTimestamp) { + + byte[] publicKey = decodePublicKey(publicKey58); + + // minimumTimestamp (if given) needs to be positive + if (minimumTimestamp != null && minimumTimestamp <= 0) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + + try (final Repository repository = RepositoryManager.getRepository()) { + Integer minimumFinalHeight = null; + + if (minimumTimestamp != null) { + minimumFinalHeight = repository.getBlockRepository().getHeightFromTimestamp(minimumTimestamp); + // If not found in the block repository it will return either 0 or 1 + if (minimumFinalHeight == 0 || minimumFinalHeight == 1) { + // Try the archive + minimumFinalHeight = repository.getBlockArchiveRepository().getHeightFromTimestamp(minimumTimestamp); + } + + if (minimumFinalHeight == 0) + // We don't have any blocks since minimumTimestamp, let alone trades, so nothing to return + return response; + + // height returned from repository is for block BEFORE timestamp + // but we want trades AFTER timestamp so bump height accordingly + minimumFinalHeight++; + } + + List crossChainTradeLedgerEntries = new ArrayList<>(); + + Map> acctsByCodeHash = SupportedBlockchain.getAcctMap(); + + // collect ledger entries for each ACCT + for (Map.Entry> acctInfo : acctsByCodeHash.entrySet()) { + byte[] codeHash = acctInfo.getKey().value; + ACCT acct = acctInfo.getValue().get(); + + // collect buys and sells + CrossChainUtils.collectLedgerEntries(publicKey, repository, minimumFinalHeight, crossChainTradeLedgerEntries, codeHash, acct, true); + CrossChainUtils.collectLedgerEntries(publicKey, repository, minimumFinalHeight, crossChainTradeLedgerEntries, codeHash, acct, false); + } + + crossChainTradeLedgerEntries.sort((a, b) -> Longs.compare(a.getTradeTimestamp(), b.getTradeTimestamp())); + + response.setStatus(HttpServletResponse.SC_OK); + response.setContentType("text/csv"); + response.setHeader( + HttpHeaders.CONTENT_DISPOSITION, + ContentDisposition + .type("attachment") + .fileName(CrossChainUtils.createLedgerFileName(Crypto.toAddress(publicKey))) + .build() + .toString() + ); + + CrossChainUtils.writeToLedger( response.getWriter(), crossChainTradeLedgerEntries); + + return response; + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } catch (IOException e) { + response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); + return response; + } + } + @GET @Path("/price/{blockchain}") @Operation( diff --git a/src/main/java/org/qortal/api/resource/CrossChainUtils.java b/src/main/java/org/qortal/api/resource/CrossChainUtils.java index 802faca1..ddd1d2d6 100644 --- a/src/main/java/org/qortal/api/resource/CrossChainUtils.java +++ b/src/main/java/org/qortal/api/resource/CrossChainUtils.java @@ -10,21 +10,36 @@ import org.bitcoinj.script.ScriptBuilder; import org.bouncycastle.util.Strings; import org.json.simple.JSONObject; +import org.qortal.api.model.CrossChainTradeLedgerEntry; import org.qortal.api.model.crosschain.BitcoinyTBDRequest; import org.qortal.crosschain.*; import org.qortal.data.at.ATData; +import org.qortal.data.at.ATStateData; import org.qortal.data.crosschain.*; import org.qortal.repository.DataException; import org.qortal.repository.Repository; +import org.qortal.utils.Amounts; import org.qortal.utils.BitTwiddling; +import java.io.BufferedWriter; +import java.io.IOException; +import java.io.OutputStreamWriter; +import java.io.PrintWriter; +import java.io.Writer; +import java.text.DateFormat; +import java.text.SimpleDateFormat; +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZonedDateTime; import java.util.*; import java.util.stream.Collectors; public class CrossChainUtils { + public static final String QORT_CURRENCY_CODE = "QORT"; private static final Logger LOGGER = LogManager.getLogger(CrossChainUtils.class); public static final String CORE_API_CALL = "Core API Call"; + public static final String QORTAL_EXCHANGE_LABEL = "Qortal"; public static ServerConfigurationInfo buildServerConfigurationInfo(Bitcoiny blockchain) { @@ -632,4 +647,128 @@ public class CrossChainUtils { byte[] lockTimeABytes = BitTwiddling.toBEByteArray((long) lockTimeA); return Bytes.concat(partnerBitcoinPKH, hashOfSecretA, lockTimeABytes); } + + /** + * Write To Ledger + * + * @param writer the writer to the ledger + * @param entries the entries to write to the ledger + * + * @throws IOException + */ + public static void writeToLedger(Writer writer, List entries) throws IOException { + + BufferedWriter bufferedWriter = new BufferedWriter(writer); + + StringJoiner header = new StringJoiner(","); + header.add("Market"); + header.add("Currency"); + header.add("Quantity"); + header.add("Commission Paid"); + header.add("Commission Currency"); + header.add("Total Price"); + header.add("Date Time"); + header.add("Exchange"); + + bufferedWriter.append(header.toString()); + + DateFormat dateFormatter = new SimpleDateFormat("yyyyMMdd HH:mm"); + dateFormatter.setTimeZone(TimeZone.getTimeZone("UTC")); + + for( CrossChainTradeLedgerEntry entry : entries ) { + StringJoiner joiner = new StringJoiner(","); + + joiner.add(entry.getMarket()); + joiner.add(entry.getCurrency()); + joiner.add(String.valueOf(Amounts.prettyAmount(entry.getQuantity()))); + joiner.add(String.valueOf(Amounts.prettyAmount(entry.getFeeAmount()))); + joiner.add(entry.getFeeCurrency()); + joiner.add(String.valueOf(Amounts.prettyAmount(entry.getTotalPrice()))); + joiner.add(dateFormatter.format(new Date(entry.getTradeTimestamp()))); + joiner.add(QORTAL_EXCHANGE_LABEL); + + bufferedWriter.newLine(); + bufferedWriter.append(joiner.toString()); + } + + bufferedWriter.newLine(); + bufferedWriter.flush(); + } + + /** + * Create Ledger File Name + * + * Create a file name the includes timestamp and address. + * + * @param address the address + * + * @return the file name created + */ + public static String createLedgerFileName(String address) { + DateFormat dateFormatter = new SimpleDateFormat("yyyyMMddHHmmss"); + String fileName = "ledger-" + address + "-" + dateFormatter.format(new Date()); + return fileName; + } + + /** + * Collect Ledger Entries + * + * @param publicKey the public key for the ledger entries, buy and sell + * @param repository the data repository + * @param minimumFinalHeight the minimum block height for entries to be collected + * @param entries the ledger entries to add to + * @param codeHash code hash for the entry blockchain + * @param acct the ACCT for the entry blockchain + * @param isBuy true collecting entries for a buy, otherwise false + * + * @throws DataException + */ + public static void collectLedgerEntries( + byte[] publicKey, + Repository repository, + Integer minimumFinalHeight, + List entries, + byte[] codeHash, + ACCT acct, + boolean isBuy) throws DataException { + + // get all the final AT states for the code hash (foreign coin) + List atStates + = repository.getATRepository().getMatchingFinalATStates( + codeHash, + isBuy ? publicKey : null, + !isBuy ? publicKey : null, + Boolean.TRUE, acct.getModeByteOffset(), + (long) AcctMode.REDEEMED.value, + minimumFinalHeight, + null, null, false + ); + + String foreignBlockchainCurrencyCode = acct.getBlockchain().getCurrencyCode(); + + // for each trade, build ledger entry, collect ledger entry + for (ATStateData atState : atStates) { + CrossChainTradeData crossChainTradeData = acct.populateTradeData(repository, atState); + + // We also need block timestamp for use as trade timestamp + long localTimestamp = repository.getBlockRepository().getTimestampFromHeight(atState.getHeight()); + + if (localTimestamp == 0) { + // Try the archive + localTimestamp = repository.getBlockArchiveRepository().getTimestampFromHeight(atState.getHeight()); + } + + CrossChainTradeLedgerEntry ledgerEntry + = new CrossChainTradeLedgerEntry( + isBuy ? QORT_CURRENCY_CODE : foreignBlockchainCurrencyCode, + isBuy ? foreignBlockchainCurrencyCode : QORT_CURRENCY_CODE, + isBuy ? crossChainTradeData.qortAmount : crossChainTradeData.expectedForeignAmount, + 0, + foreignBlockchainCurrencyCode, + isBuy ? crossChainTradeData.expectedForeignAmount : crossChainTradeData.qortAmount, + localTimestamp); + + entries.add(ledgerEntry); + } + } } \ No newline at end of file diff --git a/src/main/java/org/qortal/crosschain/Bitcoiny.java b/src/main/java/org/qortal/crosschain/Bitcoiny.java index a4f5a2af..d93fa65f 100644 --- a/src/main/java/org/qortal/crosschain/Bitcoiny.java +++ b/src/main/java/org/qortal/crosschain/Bitcoiny.java @@ -83,6 +83,7 @@ public abstract class Bitcoiny implements ForeignBlockchain { return this.bitcoinjContext; } + @Override public String getCurrencyCode() { return this.currencyCode; } diff --git a/src/main/java/org/qortal/crosschain/ForeignBlockchain.java b/src/main/java/org/qortal/crosschain/ForeignBlockchain.java index fe64ab83..c66f2719 100644 --- a/src/main/java/org/qortal/crosschain/ForeignBlockchain.java +++ b/src/main/java/org/qortal/crosschain/ForeignBlockchain.java @@ -2,6 +2,8 @@ package org.qortal.crosschain; public interface ForeignBlockchain { + public String getCurrencyCode(); + public boolean isValidAddress(String address); public boolean isValidWalletKey(String walletKey); diff --git a/src/main/java/org/qortal/repository/ATRepository.java b/src/main/java/org/qortal/repository/ATRepository.java index fe001137..2b653ab5 100644 --- a/src/main/java/org/qortal/repository/ATRepository.java +++ b/src/main/java/org/qortal/repository/ATRepository.java @@ -76,9 +76,9 @@ public interface ATRepository { * Although expectedValue, if provided, is natively an unsigned long, * the data segment comparison is done via unsigned hex string. */ - public List getMatchingFinalATStates(byte[] codeHash, String buyerAddress, String sellerAddress, Boolean isFinished, - Integer dataByteOffset, Long expectedValue, Integer minimumFinalHeight, - Integer limit, Integer offset, Boolean reverse) throws DataException; + public List getMatchingFinalATStates(byte[] codeHash, byte[] buyerPublicKey, byte[] sellerPublicKey, Boolean isFinished, + Integer dataByteOffset, Long expectedValue, Integer minimumFinalHeight, + Integer limit, Integer offset, Boolean reverse) throws DataException; /** * Returns final ATStateData for ATs matching codeHash (required) diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java index 71a95428..6310ec02 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java @@ -5,6 +5,7 @@ import com.google.common.primitives.Longs; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.qortal.controller.Controller; +import org.qortal.crypto.Crypto; import org.qortal.data.at.ATData; import org.qortal.data.at.ATStateData; import org.qortal.repository.ATRepository; @@ -403,9 +404,9 @@ public class HSQLDBATRepository implements ATRepository { } @Override - public List getMatchingFinalATStates(byte[] codeHash, String buyerAddress, String sellerAddress, Boolean isFinished, - Integer dataByteOffset, Long expectedValue, Integer minimumFinalHeight, - Integer limit, Integer offset, Boolean reverse) throws DataException { + public List getMatchingFinalATStates(byte[] codeHash, byte[] buyerPublicKey, byte[] sellerPublicKey, Boolean isFinished, + Integer dataByteOffset, Long expectedValue, Integer minimumFinalHeight, + Integer limit, Integer offset, Boolean reverse) throws DataException { StringBuilder sql = new StringBuilder(1024); List bindParams = new ArrayList<>(); @@ -426,9 +427,9 @@ public class HSQLDBATRepository implements ATRepository { // Both must be the same direction (DESC) also sql.append("ORDER BY ATStates.height DESC LIMIT 1) AS FinalATStates "); - // Optional LEFT JOIN with ATTRANSACTIONS for buyerAddress - if (buyerAddress != null && !buyerAddress.isEmpty()) { - sql.append("LEFT JOIN ATTRANSACTIONS tx ON tx.at_address = ATs.AT_address "); + // Optional JOIN with ATTRANSACTIONS for buyerAddress + if (buyerPublicKey != null && buyerPublicKey.length > 0) { + sql.append("JOIN ATTRANSACTIONS tx ON tx.at_address = ATs.AT_address "); } sql.append("WHERE ATs.code_hash = ? "); @@ -450,18 +451,18 @@ public class HSQLDBATRepository implements ATRepository { bindParams.add(rawExpectedValue); } - if (buyerAddress != null && !buyerAddress.isEmpty()) { - sql.append("AND tx.recipient = ? "); - bindParams.add(buyerAddress); + if (buyerPublicKey != null && buyerPublicKey.length > 0 ) { + // the buyer must be the recipient of the transaction and not the creator of the AT + sql.append("AND tx.recipient = ? AND ATs.creator != ? "); + + bindParams.add(Crypto.toAddress(buyerPublicKey)); + bindParams.add(buyerPublicKey); } - if (sellerAddress != null && !sellerAddress.isEmpty()) { - // Convert sellerAddress to publicKey (method depends on your implementation) - AccountData accountData = this.repository.getAccountRepository().getAccount(sellerAddress); - byte[] publicKey = accountData.getPublicKey(); + if (sellerPublicKey != null && sellerPublicKey.length > 0) { sql.append("AND ATs.creator = ? "); - bindParams.add(publicKey); + bindParams.add(sellerPublicKey); } sql.append(" ORDER BY FinalATStates.height "); diff --git a/src/test/java/org/qortal/test/api/CrossChainUtilsTests.java b/src/test/java/org/qortal/test/api/CrossChainUtilsTests.java index 0e4a6f07..5c67267f 100644 --- a/src/test/java/org/qortal/test/api/CrossChainUtilsTests.java +++ b/src/test/java/org/qortal/test/api/CrossChainUtilsTests.java @@ -3,10 +3,15 @@ package org.qortal.test.api; import org.json.simple.JSONObject; import org.junit.Assert; import org.junit.Test; +import org.qortal.api.model.CrossChainTradeLedgerEntry; import org.qortal.api.resource.CrossChainUtils; import org.qortal.test.common.ApiCommon; +import java.io.IOException; +import java.io.PrintWriter; +import java.util.ArrayList; import java.util.HashMap; +import java.util.List; import java.util.Map; public class CrossChainUtilsTests extends ApiCommon { @@ -137,4 +142,53 @@ public class CrossChainUtilsTests extends ApiCommon { Assert.assertEquals(5, versionDecimal, 0.001); Assert.assertFalse(thrown); } + + @Test + public void testWriteToLedgerHeaderOnly() throws IOException { + CrossChainUtils.writeToLedger(new PrintWriter(System.out), new ArrayList<>()); + } + + @Test + public void testWriteToLedgerOneRow() throws IOException { + CrossChainUtils.writeToLedger( + new PrintWriter(System.out), + List.of( + new CrossChainTradeLedgerEntry( + "QORT", + "LTC", + 1000, + 0, + "LTC", + 1, + System.currentTimeMillis()) + ) + ); + } + + @Test + public void testWriteToLedgerTwoRows() throws IOException { + CrossChainUtils.writeToLedger( + new PrintWriter(System.out), + List.of( + new CrossChainTradeLedgerEntry( + "QORT", + "LTC", + 1000, + 0, + "LTC", + 1, + System.currentTimeMillis() + ), + new CrossChainTradeLedgerEntry( + "LTC", + "QORT", + 1, + 0, + "LTC", + 1000, + System.currentTimeMillis() + ) + ) + ); + } }