diff --git a/.gitignore b/.gitignore index 890f8cb2..005ab005 100644 --- a/.gitignore +++ b/.gitignore @@ -15,8 +15,8 @@ /settings.json /testnet* /settings*.json -/testchain.json -/run-testnet.sh +/testchain*.json +/run-testnet*.sh /.idea /qortal.iml .DS_Store diff --git a/src/main/java/org/qortal/at/AT.java b/src/main/java/org/qortal/at/AT.java index e82ab14e..99ae57d5 100644 --- a/src/main/java/org/qortal/at/AT.java +++ b/src/main/java/org/qortal/at/AT.java @@ -1,5 +1,7 @@ package org.qortal.at; +import java.util.Arrays; +import java.util.Collections; import java.util.List; import org.ciyam.at.MachineState; @@ -56,12 +58,12 @@ public class AT { this.atData = new ATData(atAddress, creatorPublicKey, creation, machineState.version, assetId, codeBytes, codeHash, machineState.isSleeping(), machineState.getSleepUntilHeight(), machineState.isFinished(), machineState.hadFatalError(), - machineState.isFrozen(), machineState.getFrozenBalance()); + machineState.isFrozen(), machineState.getFrozenBalance(), null); byte[] stateData = machineState.toBytes(); byte[] stateHash = Crypto.digest(stateData); - this.atStateData = new ATStateData(atAddress, height, stateData, stateHash, 0L, true); + this.atStateData = new ATStateData(atAddress, height, stateData, stateHash, 0L, true, null); } // Getters / setters @@ -84,13 +86,28 @@ public class AT { this.repository.getATRepository().delete(this.atData.getATAddress()); } + /** + * Potentially execute AT. + *

+ * Note that sleep-until-message support might set/reset + * sleep-related flags/values. + *

+ * {@link #getATStateData()} will return null if nothing happened. + *

+ * @param blockHeight + * @param blockTimestamp + * @return AT-generated transactions, possibly empty + * @throws DataException + */ public List run(int blockHeight, long blockTimestamp) throws DataException { String atAddress = this.atData.getATAddress(); QortalATAPI api = new QortalATAPI(repository, this.atData, blockTimestamp); QortalAtLoggerFactory loggerFactory = QortalAtLoggerFactory.getInstance(); - byte[] codeBytes = this.atData.getCodeBytes(); + if (!api.willExecute(blockHeight)) + // this.atStateData will be null + return Collections.emptyList(); // Fetch latest ATStateData for this AT ATStateData latestAtStateData = this.repository.getATRepository().getLatestATState(atAddress); @@ -100,8 +117,10 @@ public class AT { throw new IllegalStateException("No previous AT state data found"); // [Re]create AT machine state using AT state data or from scratch as applicable + byte[] codeBytes = this.atData.getCodeBytes(); MachineState state = MachineState.fromBytes(api, loggerFactory, latestAtStateData.getStateData(), codeBytes); try { + api.preExecute(state); state.execute(); } catch (Exception e) { throw new DataException(String.format("Uncaught exception while running AT '%s'", atAddress), e); @@ -109,9 +128,16 @@ public class AT { byte[] stateData = state.toBytes(); byte[] stateHash = Crypto.digest(stateData); - long atFees = api.calcFinalFees(state); - this.atStateData = new ATStateData(atAddress, blockHeight, stateData, stateHash, atFees, false); + // Nothing happened? + if (state.getSteps() == 0 && Arrays.equals(stateHash, latestAtStateData.getStateHash())) + // this.atStateData will be null + return Collections.emptyList(); + + long atFees = api.calcFinalFees(state); + Long sleepUntilMessageTimestamp = this.atData.getSleepUntilMessageTimestamp(); + + this.atStateData = new ATStateData(atAddress, blockHeight, stateData, stateHash, atFees, false, sleepUntilMessageTimestamp); return api.getTransactions(); } @@ -130,6 +156,10 @@ public class AT { this.atData.setHadFatalError(state.hadFatalError()); this.atData.setIsFrozen(state.isFrozen()); this.atData.setFrozenBalance(state.getFrozenBalance()); + + // Special sleep-until-message support + this.atData.setSleepUntilMessageTimestamp(this.atStateData.getSleepUntilMessageTimestamp()); + this.repository.getATRepository().save(this.atData); } @@ -157,6 +187,10 @@ public class AT { this.atData.setHadFatalError(state.hadFatalError()); this.atData.setIsFrozen(state.isFrozen()); this.atData.setFrozenBalance(state.getFrozenBalance()); + + // Special sleep-until-message support + this.atData.setSleepUntilMessageTimestamp(previousStateData.getSleepUntilMessageTimestamp()); + this.repository.getATRepository().save(this.atData); } diff --git a/src/main/java/org/qortal/at/QortalATAPI.java b/src/main/java/org/qortal/at/QortalATAPI.java index 6a379d59..c393a684 100644 --- a/src/main/java/org/qortal/at/QortalATAPI.java +++ b/src/main/java/org/qortal/at/QortalATAPI.java @@ -32,6 +32,7 @@ import org.qortal.group.Group; import org.qortal.repository.ATRepository; import org.qortal.repository.DataException; import org.qortal.repository.Repository; +import org.qortal.repository.ATRepository.NextTransactionInfo; import org.qortal.transaction.AtTransaction; import org.qortal.transaction.Transaction.TransactionType; import org.qortal.utils.Base58; @@ -74,8 +75,45 @@ public class QortalATAPI extends API { return this.transactions; } - public long calcFinalFees(MachineState state) { - return state.getSteps() * this.ciyamAtSettings.feePerStep; + public boolean willExecute(int blockHeight) throws DataException { + // Sleep-until-message/height checking + Long sleepUntilMessageTimestamp = this.atData.getSleepUntilMessageTimestamp(); + + if (sleepUntilMessageTimestamp != null) { + // Quicker to check height, if sleep-until-height also active + Integer sleepUntilHeight = this.atData.getSleepUntilHeight(); + + boolean wakeDueToHeight = sleepUntilHeight != null && sleepUntilHeight != 0 && blockHeight >= sleepUntilHeight; + + boolean wakeDueToMessage = false; + if (!wakeDueToHeight) { + // No avoiding asking repository + Timestamp previousTxTimestamp = new Timestamp(sleepUntilMessageTimestamp); + NextTransactionInfo nextTransactionInfo = this.repository.getATRepository().findNextTransaction(this.atData.getATAddress(), + previousTxTimestamp.blockHeight, + previousTxTimestamp.transactionSequence); + + wakeDueToMessage = nextTransactionInfo != null; + } + + // Can we skip? + if (!wakeDueToHeight && !wakeDueToMessage) + return false; + } + + return true; + } + + public void preExecute(MachineState state) { + // Sleep-until-message/height checking + Long sleepUntilMessageTimestamp = this.atData.getSleepUntilMessageTimestamp(); + + if (sleepUntilMessageTimestamp != null) { + // We've passed checks, so clear sleep-related flags/values + this.setIsSleeping(state, false); + this.setSleepUntilHeight(state, 0); + this.atData.setSleepUntilMessageTimestamp(null); + } } // Inherited methods from CIYAM AT API @@ -412,6 +450,10 @@ public class QortalATAPI extends API { // Utility methods + public long calcFinalFees(MachineState state) { + return state.getSteps() * this.ciyamAtSettings.feePerStep; + } + /** Returns partial transaction signature, used to verify we're operating on the same transaction and not naively using block height & sequence. */ public static byte[] partialSignature(byte[] fullSignature) { return Arrays.copyOfRange(fullSignature, 8, 32); @@ -460,6 +502,15 @@ public class QortalATAPI extends API { } } + /*package*/ void sleepUntilMessageOrHeight(MachineState state, long txTimestamp, Long sleepUntilHeight) { + this.setIsSleeping(state, true); + + this.atData.setSleepUntilMessageTimestamp(txTimestamp); + + if (sleepUntilHeight != null) + this.setSleepUntilHeight(state, sleepUntilHeight.intValue()); + } + /** Returns AT's account */ /* package */ Account getATAccount() { return new Account(this.repository, this.atData.getATAddress()); diff --git a/src/main/java/org/qortal/at/QortalFunctionCode.java b/src/main/java/org/qortal/at/QortalFunctionCode.java index 0d11e488..7069290a 100644 --- a/src/main/java/org/qortal/at/QortalFunctionCode.java +++ b/src/main/java/org/qortal/at/QortalFunctionCode.java @@ -84,6 +84,43 @@ public enum QortalFunctionCode { api.setB(state, bBytes); } }, + /** + * Sleep AT until a new message arrives after 'tx-timestamp'.
+ * 0x0503 tx-timestamp + */ + SLEEP_UNTIL_MESSAGE(0x0503, 1, false) { + @Override + protected void postCheckExecute(FunctionData functionData, MachineState state, short rawFunctionCode) throws ExecutionException { + if (functionData.value1 <= 0) + return; + + long txTimestamp = functionData.value1; + + QortalATAPI api = (QortalATAPI) state.getAPI(); + api.sleepUntilMessageOrHeight(state, txTimestamp, null); + } + }, + /** + * Sleep AT until a new message arrives, after 'tx-timestamp', or height reached.
+ * 0x0504 tx-timestamp height + */ + SLEEP_UNTIL_MESSAGE_OR_HEIGHT(0x0504, 2, false) { + @Override + protected void postCheckExecute(FunctionData functionData, MachineState state, short rawFunctionCode) throws ExecutionException { + if (functionData.value1 <= 0) + return; + + long txTimestamp = functionData.value1; + + if (functionData.value2 <= 0) + return; + + long sleepUntilHeight = functionData.value2; + + QortalATAPI api = (QortalATAPI) state.getAPI(); + api.sleepUntilMessageOrHeight(state, txTimestamp, sleepUntilHeight); + } + }, /** * Convert address in B to 20-byte value in LSB of B1, and all of B2 & B3.
* 0x0510 diff --git a/src/main/java/org/qortal/block/Block.java b/src/main/java/org/qortal/block/Block.java index 3cb134ff..11aab89c 100644 --- a/src/main/java/org/qortal/block/Block.java +++ b/src/main/java/org/qortal/block/Block.java @@ -1247,12 +1247,13 @@ public class Block { for (ATData atData : executableATs) { AT at = new AT(this.repository, atData); List atTransactions = at.run(this.blockData.getHeight(), this.blockData.getTimestamp()); + ATStateData atStateData = at.getATStateData(); + // Didn't execute? (e.g. sleeping) + if (atStateData == null) + continue; allAtTransactions.addAll(atTransactions); - - ATStateData atStateData = at.getATStateData(); this.ourAtStates.add(atStateData); - this.ourAtFees += atStateData.getFees(); } diff --git a/src/main/java/org/qortal/data/at/ATData.java b/src/main/java/org/qortal/data/at/ATData.java index 02f79f84..9e977acf 100644 --- a/src/main/java/org/qortal/data/at/ATData.java +++ b/src/main/java/org/qortal/data/at/ATData.java @@ -23,6 +23,7 @@ public class ATData { private boolean isFrozen; @XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class) private Long frozenBalance; + private Long sleepUntilMessageTimestamp; // Constructors @@ -31,7 +32,8 @@ public class ATData { } public ATData(String ATAddress, byte[] creatorPublicKey, long creation, int version, long assetId, byte[] codeBytes, byte[] codeHash, - boolean isSleeping, Integer sleepUntilHeight, boolean isFinished, boolean hadFatalError, boolean isFrozen, Long frozenBalance) { + boolean isSleeping, Integer sleepUntilHeight, boolean isFinished, boolean hadFatalError, boolean isFrozen, Long frozenBalance, + Long sleepUntilMessageTimestamp) { this.ATAddress = ATAddress; this.creatorPublicKey = creatorPublicKey; this.creation = creation; @@ -45,6 +47,7 @@ public class ATData { this.hadFatalError = hadFatalError; this.isFrozen = isFrozen; this.frozenBalance = frozenBalance; + this.sleepUntilMessageTimestamp = sleepUntilMessageTimestamp; } /** For constructing skeleton ATData with bare minimum info. */ @@ -133,4 +136,12 @@ public class ATData { this.frozenBalance = frozenBalance; } + public Long getSleepUntilMessageTimestamp() { + return this.sleepUntilMessageTimestamp; + } + + public void setSleepUntilMessageTimestamp(Long sleepUntilMessageTimestamp) { + this.sleepUntilMessageTimestamp = sleepUntilMessageTimestamp; + } + } diff --git a/src/main/java/org/qortal/data/at/ATStateData.java b/src/main/java/org/qortal/data/at/ATStateData.java index e689f5ae..ddace8e3 100644 --- a/src/main/java/org/qortal/data/at/ATStateData.java +++ b/src/main/java/org/qortal/data/at/ATStateData.java @@ -10,35 +10,32 @@ public class ATStateData { private Long fees; private boolean isInitial; + // Qortal-AT-specific + private Long sleepUntilMessageTimestamp; + // Constructors /** Create new ATStateData */ - public ATStateData(String ATAddress, Integer height, byte[] stateData, byte[] stateHash, Long fees, boolean isInitial) { + public ATStateData(String ATAddress, Integer height, byte[] stateData, byte[] stateHash, Long fees, + boolean isInitial, Long sleepUntilMessageTimestamp) { this.ATAddress = ATAddress; this.height = height; this.stateData = stateData; this.stateHash = stateHash; this.fees = fees; this.isInitial = isInitial; + this.sleepUntilMessageTimestamp = sleepUntilMessageTimestamp; } /** For recreating per-block ATStateData from repository where not all info is needed */ public ATStateData(String ATAddress, int height, byte[] stateHash, Long fees, boolean isInitial) { - this(ATAddress, height, null, stateHash, fees, isInitial); - } - - /** For creating ATStateData from serialized bytes when we don't have all the info */ - public ATStateData(String ATAddress, byte[] stateHash) { - // This won't ever be initial AT state from deployment as that's never serialized over the network, - // but generated when the DeployAtTransaction is processed locally. - this(ATAddress, null, null, stateHash, null, false); + this(ATAddress, height, null, stateHash, fees, isInitial, null); } /** For creating ATStateData from serialized bytes when we don't have all the info */ public ATStateData(String ATAddress, byte[] stateHash, Long fees) { - // This won't ever be initial AT state from deployment as that's never serialized over the network, - // but generated when the DeployAtTransaction is processed locally. - this(ATAddress, null, null, stateHash, fees, false); + // This won't ever be initial AT state from deployment, as that's never serialized over the network. + this(ATAddress, null, null, stateHash, fees, false, null); } // Getters / setters @@ -72,4 +69,12 @@ public class ATStateData { return this.isInitial; } + public Long getSleepUntilMessageTimestamp() { + return this.sleepUntilMessageTimestamp; + } + + public void setSleepUntilMessageTimestamp(Long sleepUntilMessageTimestamp) { + this.sleepUntilMessageTimestamp = sleepUntilMessageTimestamp; + } + } diff --git a/src/main/java/org/qortal/repository/ATRepository.java b/src/main/java/org/qortal/repository/ATRepository.java index 5516ac28..558b3aab 100644 --- a/src/main/java/org/qortal/repository/ATRepository.java +++ b/src/main/java/org/qortal/repository/ATRepository.java @@ -103,7 +103,7 @@ public interface ATRepository { /** * Returns all ATStateData for a given block height. *

- * Unlike getATState, only returns ATStateData saved at the given height. + * Unlike getATState, only returns partial ATStateData saved at the given height. * * @param height * - block height diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java index c21dbf8c..d2461466 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java @@ -32,7 +32,7 @@ public class HSQLDBATRepository implements ATRepository { public ATData fromATAddress(String atAddress) throws DataException { String sql = "SELECT creator, created_when, version, asset_id, code_bytes, code_hash, " + "is_sleeping, sleep_until_height, is_finished, had_fatal_error, " - + "is_frozen, frozen_balance " + + "is_frozen, frozen_balance, sleep_until_message_timestamp " + "FROM ATs " + "WHERE AT_address = ? LIMIT 1"; @@ -60,8 +60,13 @@ public class HSQLDBATRepository implements ATRepository { if (frozenBalance == 0 && resultSet.wasNull()) frozenBalance = null; + Long sleepUntilMessageTimestamp = resultSet.getLong(13); + if (sleepUntilMessageTimestamp == 0 && resultSet.wasNull()) + sleepUntilMessageTimestamp = null; + return new ATData(atAddress, creatorPublicKey, created, version, assetId, codeBytes, codeHash, - isSleeping, sleepUntilHeight, isFinished, hadFatalError, isFrozen, frozenBalance); + isSleeping, sleepUntilHeight, isFinished, hadFatalError, isFrozen, frozenBalance, + sleepUntilMessageTimestamp); } catch (SQLException e) { throw new DataException("Unable to fetch AT from repository", e); } @@ -94,7 +99,7 @@ public class HSQLDBATRepository implements ATRepository { public List getAllExecutableATs() throws DataException { String sql = "SELECT AT_address, creator, created_when, version, asset_id, code_bytes, code_hash, " + "is_sleeping, sleep_until_height, had_fatal_error, " - + "is_frozen, frozen_balance " + + "is_frozen, frozen_balance, sleep_until_message_timestamp " + "FROM ATs " + "WHERE is_finished = false " + "ORDER BY created_when ASC"; @@ -128,8 +133,13 @@ public class HSQLDBATRepository implements ATRepository { if (frozenBalance == 0 && resultSet.wasNull()) frozenBalance = null; + Long sleepUntilMessageTimestamp = resultSet.getLong(13); + if (sleepUntilMessageTimestamp == 0 && resultSet.wasNull()) + sleepUntilMessageTimestamp = null; + ATData atData = new ATData(atAddress, creatorPublicKey, created, version, assetId, codeBytes, codeHash, - isSleeping, sleepUntilHeight, isFinished, hadFatalError, isFrozen, frozenBalance); + isSleeping, sleepUntilHeight, isFinished, hadFatalError, isFrozen, frozenBalance, + sleepUntilMessageTimestamp); executableATs.add(atData); } while (resultSet.next()); @@ -147,7 +157,7 @@ public class HSQLDBATRepository implements ATRepository { sql.append("SELECT AT_address, creator, created_when, version, asset_id, code_bytes, ") .append("is_sleeping, sleep_until_height, is_finished, had_fatal_error, ") - .append("is_frozen, frozen_balance ") + .append("is_frozen, frozen_balance, sleep_until_message_timestamp ") .append("FROM ATs ") .append("WHERE code_hash = ? "); bindParams.add(codeHash); @@ -191,8 +201,13 @@ public class HSQLDBATRepository implements ATRepository { if (frozenBalance == 0 && resultSet.wasNull()) frozenBalance = null; + Long sleepUntilMessageTimestamp = resultSet.getLong(13); + if (sleepUntilMessageTimestamp == 0 && resultSet.wasNull()) + sleepUntilMessageTimestamp = null; + ATData atData = new ATData(atAddress, creatorPublicKey, created, version, assetId, codeBytes, codeHash, - isSleeping, sleepUntilHeight, isFinished, hadFatalError, isFrozen, frozenBalance); + isSleeping, sleepUntilHeight, isFinished, hadFatalError, isFrozen, frozenBalance, + sleepUntilMessageTimestamp); matchingATs.add(atData); } while (resultSet.next()); @@ -210,7 +225,7 @@ public class HSQLDBATRepository implements ATRepository { sql.append("SELECT AT_address, creator, created_when, version, asset_id, code_bytes, ") .append("is_sleeping, sleep_until_height, is_finished, had_fatal_error, ") - .append("is_frozen, frozen_balance, code_hash ") + .append("is_frozen, frozen_balance, code_hash, sleep_until_message_timestamp ") .append("FROM "); // (VALUES (?), (?), ...) AS ATCodeHashes (code_hash) @@ -264,9 +279,10 @@ public class HSQLDBATRepository implements ATRepository { frozenBalance = null; byte[] codeHash = resultSet.getBytes(13); + Long sleepUntilMessageTimestamp = resultSet.getLong(14); ATData atData = new ATData(atAddress, creatorPublicKey, created, version, assetId, codeBytes, codeHash, - isSleeping, sleepUntilHeight, isFinished, hadFatalError, isFrozen, frozenBalance); + isSleeping, sleepUntilHeight, isFinished, hadFatalError, isFrozen, frozenBalance, sleepUntilMessageTimestamp); matchingATs.add(atData); } while (resultSet.next()); @@ -305,7 +321,7 @@ public class HSQLDBATRepository implements ATRepository { .bind("code_bytes", atData.getCodeBytes()).bind("code_hash", atData.getCodeHash()) .bind("is_sleeping", atData.getIsSleeping()).bind("sleep_until_height", atData.getSleepUntilHeight()) .bind("is_finished", atData.getIsFinished()).bind("had_fatal_error", atData.getHadFatalError()).bind("is_frozen", atData.getIsFrozen()) - .bind("frozen_balance", atData.getFrozenBalance()); + .bind("frozen_balance", atData.getFrozenBalance()).bind("sleep_until_message_timestamp", atData.getSleepUntilMessageTimestamp()); try { saveHelper.execute(this.repository); @@ -328,7 +344,7 @@ public class HSQLDBATRepository implements ATRepository { @Override public ATStateData getATStateAtHeight(String atAddress, int height) throws DataException { - String sql = "SELECT state_data, state_hash, fees, is_initial " + String sql = "SELECT state_data, state_hash, fees, is_initial, sleep_until_message_timestamp " + "FROM ATStates " + "LEFT OUTER JOIN ATStatesData USING (AT_address, height) " + "WHERE ATStates.AT_address = ? AND ATStates.height = ? " @@ -343,7 +359,11 @@ public class HSQLDBATRepository implements ATRepository { long fees = resultSet.getLong(3); boolean isInitial = resultSet.getBoolean(4); - return new ATStateData(atAddress, height, stateData, stateHash, fees, isInitial); + Long sleepUntilMessageTimestamp = resultSet.getLong(5); + if (sleepUntilMessageTimestamp == 0 && resultSet.wasNull()) + sleepUntilMessageTimestamp = null; + + return new ATStateData(atAddress, height, stateData, stateHash, fees, isInitial, sleepUntilMessageTimestamp); } catch (SQLException e) { throw new DataException("Unable to fetch AT state from repository", e); } @@ -351,7 +371,7 @@ public class HSQLDBATRepository implements ATRepository { @Override public ATStateData getLatestATState(String atAddress) throws DataException { - String sql = "SELECT height, state_data, state_hash, fees, is_initial " + String sql = "SELECT height, state_data, state_hash, fees, is_initial, sleep_until_message_timestamp " + "FROM ATStates " + "JOIN ATStatesData USING (AT_address, height) " + "WHERE ATStates.AT_address = ? " @@ -370,7 +390,11 @@ public class HSQLDBATRepository implements ATRepository { long fees = resultSet.getLong(4); boolean isInitial = resultSet.getBoolean(5); - return new ATStateData(atAddress, height, stateData, stateHash, fees, isInitial); + Long sleepUntilMessageTimestamp = resultSet.getLong(6); + if (sleepUntilMessageTimestamp == 0 && resultSet.wasNull()) + sleepUntilMessageTimestamp = null; + + return new ATStateData(atAddress, height, stateData, stateHash, fees, isInitial, sleepUntilMessageTimestamp); } catch (SQLException e) { throw new DataException("Unable to fetch latest AT state from repository", e); } @@ -383,10 +407,10 @@ public class HSQLDBATRepository implements ATRepository { StringBuilder sql = new StringBuilder(1024); List bindParams = new ArrayList<>(); - sql.append("SELECT AT_address, height, state_data, state_hash, fees, is_initial " + sql.append("SELECT AT_address, height, state_data, state_hash, fees, is_initial, FinalATStates.sleep_until_message_timestamp " + "FROM ATs " + "CROSS JOIN LATERAL(" - + "SELECT height, state_data, state_hash, fees, is_initial " + + "SELECT height, state_data, state_hash, fees, is_initial, sleep_until_message_timestamp " + "FROM ATStates " + "JOIN ATStatesData USING (AT_address, height) " + "WHERE ATStates.AT_address = ATs.AT_address "); @@ -440,7 +464,11 @@ public class HSQLDBATRepository implements ATRepository { long fees = resultSet.getLong(5); boolean isInitial = resultSet.getBoolean(6); - ATStateData atStateData = new ATStateData(atAddress, height, stateData, stateHash, fees, isInitial); + Long sleepUntilMessageTimestamp = resultSet.getLong(7); + if (sleepUntilMessageTimestamp == 0 && resultSet.wasNull()) + sleepUntilMessageTimestamp = null; + + ATStateData atStateData = new ATStateData(atAddress, height, stateData, stateHash, fees, isInitial, sleepUntilMessageTimestamp); atStates.add(atStateData); } while (resultSet.next()); @@ -471,7 +499,7 @@ public class HSQLDBATRepository implements ATRepository { StringBuilder sql = new StringBuilder(1024); List bindParams = new ArrayList<>(); - sql.append("SELECT AT_address, height, state_data, state_hash, fees, is_initial " + sql.append("SELECT AT_address, height, state_data, state_hash, fees, is_initial, sleep_until_message_timestamp " + "FROM ATs " + "CROSS JOIN LATERAL(" + "SELECT height, state_data, state_hash, fees, is_initial " @@ -526,8 +554,10 @@ public class HSQLDBATRepository implements ATRepository { byte[] stateHash = resultSet.getBytes(4); long fees = resultSet.getLong(5); boolean isInitial = resultSet.getBoolean(6); + Long sleepUntilMessageTimestamp = resultSet.getLong(7); - ATStateData atStateData = new ATStateData(atAddress, height, stateData, stateHash, fees, isInitial); + ATStateData atStateData = new ATStateData(atAddress, height, stateData, stateHash, fees, isInitial, + sleepUntilMessageTimestamp); atStates.add(atStateData); } while (resultSet.next()); @@ -662,7 +692,8 @@ public class HSQLDBATRepository implements ATRepository { atStatesSaver.bind("AT_address", atStateData.getATAddress()).bind("height", atStateData.getHeight()) .bind("state_hash", atStateData.getStateHash()) - .bind("fees", atStateData.getFees()).bind("is_initial", atStateData.isInitial()); + .bind("fees", atStateData.getFees()).bind("is_initial", atStateData.isInitial()) + .bind("sleep_until_message_timestamp", atStateData.getSleepUntilMessageTimestamp()); try { atStatesSaver.execute(this.repository); diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java index b82f55c3..6dfef623 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java @@ -699,7 +699,7 @@ public class HSQLDBDatabaseUpdates { stmt.execute("CHECKPOINT"); break; - case 30: + case 30: { // Split AT state data off to new table for better performance/management. if (!wasPristine && !"mem".equals(HSQLDBRepository.getDbPathname(connection.getMetaData().getURL()))) { @@ -774,6 +774,7 @@ public class HSQLDBDatabaseUpdates { stmt.execute("ALTER TABLE ATStatesNew RENAME TO ATStates"); stmt.execute("CHECKPOINT"); break; + } case 31: // Fix latest AT state cache which was previous created as TEMPORARY @@ -822,6 +823,41 @@ public class HSQLDBDatabaseUpdates { + "timestamp_signature Signature NOT NULL, " + TRANSACTION_KEYS + ")"); break; + case 34: { + // AT sleep-until-message support + LOGGER.info("Altering AT table in repository - this might take a while... (approx. 20 seconds on high-spec)"); + stmt.execute("ALTER TABLE ATs ADD sleep_until_message_timestamp BIGINT"); + + // Create new AT-states table with new column + stmt.execute("CREATE TABLE ATStatesNew (" + + "AT_address QortalAddress, height INTEGER NOT NULL, state_hash ATStateHash NOT NULL, " + + "fees QortalAmount NOT NULL, is_initial BOOLEAN NOT NULL, sleep_until_message_timestamp BIGINT, " + + "PRIMARY KEY (AT_address, height), " + + "FOREIGN KEY (AT_address) REFERENCES ATs (AT_address) ON DELETE CASCADE)"); + stmt.execute("SET TABLE ATStatesNew NEW SPACE"); + stmt.execute("CHECKPOINT"); + + ResultSet resultSet = stmt.executeQuery("SELECT height FROM Blocks ORDER BY height DESC LIMIT 1"); + final int blockchainHeight = resultSet.next() ? resultSet.getInt(1) : 0; + final int heightStep = 100; + + LOGGER.info("Altering AT states table in repository - this might take a while... (approx. 3 mins on high-spec)"); + for (int minHeight = 1; minHeight < blockchainHeight; minHeight += heightStep) { + stmt.execute("INSERT INTO ATStatesNew (" + + "SELECT AT_address, height, state_hash, fees, is_initial, NULL " + + "FROM ATStates " + + "WHERE height BETWEEN " + minHeight + " AND " + (minHeight + heightStep - 1) + + ")"); + stmt.execute("COMMIT"); + } + stmt.execute("CHECKPOINT"); + + stmt.execute("DROP TABLE ATStates"); + stmt.execute("ALTER TABLE ATStatesNew RENAME TO ATStates"); + stmt.execute("CHECKPOINT"); + break; + } + default: // nothing to do return false; diff --git a/src/test/java/org/qortal/test/at/AtRepositoryTests.java b/src/test/java/org/qortal/test/at/AtRepositoryTests.java index 9aed7296..c7dfa423 100644 --- a/src/test/java/org/qortal/test/at/AtRepositoryTests.java +++ b/src/test/java/org/qortal/test/at/AtRepositoryTests.java @@ -354,7 +354,8 @@ public class AtRepositoryTests extends Common { /*StateData*/ null, atStateData.getStateHash(), atStateData.getFees(), - atStateData.isInitial()); + atStateData.isInitial(), + atStateData.getSleepUntilMessageTimestamp()); repository.getATRepository().save(newAtStateData); atStateData = repository.getATRepository().getATStateAtHeight(atAddress, testHeight); diff --git a/src/test/java/org/qortal/test/at/SleepUntilMessageOrHeightTests.java b/src/test/java/org/qortal/test/at/SleepUntilMessageOrHeightTests.java new file mode 100644 index 00000000..7ac952d2 --- /dev/null +++ b/src/test/java/org/qortal/test/at/SleepUntilMessageOrHeightTests.java @@ -0,0 +1,365 @@ +package org.qortal.test.at; + +import static org.junit.Assert.*; + +import java.nio.ByteBuffer; +import java.util.Arrays; +import java.util.List; + +import org.ciyam.at.CompilationException; +import org.ciyam.at.FunctionCode; +import org.ciyam.at.MachineState; +import org.ciyam.at.OpCode; +import org.ciyam.at.Timestamp; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.qortal.account.Account; +import org.qortal.account.PrivateKeyAccount; +import org.qortal.asset.Asset; +import org.qortal.at.QortalFunctionCode; +import org.qortal.block.Block; +import org.qortal.data.at.ATStateData; +import org.qortal.data.block.BlockData; +import org.qortal.data.transaction.BaseTransactionData; +import org.qortal.data.transaction.DeployAtTransactionData; +import org.qortal.data.transaction.MessageTransactionData; +import org.qortal.data.transaction.TransactionData; +import org.qortal.group.Group; +import org.qortal.repository.DataException; +import org.qortal.repository.Repository; +import org.qortal.repository.RepositoryManager; +import org.qortal.test.common.BlockUtils; +import org.qortal.test.common.Common; +import org.qortal.test.common.TransactionUtils; +import org.qortal.transaction.DeployAtTransaction; +import org.qortal.transaction.MessageTransaction; +import org.qortal.transaction.Transaction; +import org.qortal.utils.BitTwiddling; + +public class SleepUntilMessageOrHeightTests extends Common { + + private static final byte[] messageData = new byte[] { 0x44 }; + private static final byte[] creationBytes = buildSleepUntilMessageOrHeightAT(); + private static final long fundingAmount = 1_00000000L; + private static final long WAKE_HEIGHT = 10L; + + private Repository repository = null; + private PrivateKeyAccount deployer; + private DeployAtTransaction deployAtTransaction; + private Account atAccount; + private String atAddress; + private byte[] rawNextTimestamp = new byte[32]; + private Transaction transaction; + + @Before + public void before() throws DataException { + Common.useDefaultSettings(); + + this.repository = RepositoryManager.getRepository(); + this.deployer = Common.getTestAccount(repository, "alice"); + + this.deployAtTransaction = doDeploy(repository, deployer, creationBytes, fundingAmount); + this.atAccount = deployAtTransaction.getATAccount(); + this.atAddress = deployAtTransaction.getATAccount().getAddress(); + } + + @After + public void after() throws DataException { + if (this.repository != null) + this.repository.close(); + + this.repository = null; + } + + @Test + public void testDeploy() throws DataException { + // Confirm initial value is zero + extractNextTxTimestamp(repository, atAddress, rawNextTimestamp); + assertArrayEquals(new byte[32], rawNextTimestamp); + } + + @Test + public void testFeelessSleep() throws DataException { + // Mint block to allow AT to initialize and call SLEEP_UNTIL_MESSAGE + BlockUtils.mintBlock(repository); + + // Fetch AT's balance for this height + long preMintBalance = atAccount.getConfirmedBalance(Asset.QORT); + + // Mint block + BlockUtils.mintBlock(repository); + + // Fetch new AT balance + long postMintBalance = atAccount.getConfirmedBalance(Asset.QORT); + + assertEquals(preMintBalance, postMintBalance); + } + + @Test + public void testFeelessSleep2() throws DataException { + // Mint block to allow AT to initialize and call SLEEP_UNTIL_MESSAGE + BlockUtils.mintBlock(repository); + + // Fetch AT's balance for this height + long preMintBalance = atAccount.getConfirmedBalance(Asset.QORT); + + // Mint several blocks + for (int i = 0; i < 5; ++i) + BlockUtils.mintBlock(repository); + + // Fetch new AT balance + long postMintBalance = atAccount.getConfirmedBalance(Asset.QORT); + + assertEquals(preMintBalance, postMintBalance); + } + + @Test + public void testSleepUntilMessage() throws DataException { + // Mint block to allow AT to initialize and call SLEEP_UNTIL_MESSAGE_OR_HEIGHT + BlockUtils.mintBlock(repository); + + // Send message to AT + transaction = sendMessage(repository, deployer, messageData, atAddress); + BlockUtils.mintBlock(repository); + + // Mint block so AT executes and finds message + BlockUtils.mintBlock(repository); + + // Confirm AT finds message + assertTimestamp(repository, atAddress, transaction); + } + + @Test + public void testSleepUntilHeight() throws DataException { + // AT deployment in block 2 + + // Mint block to allow AT to initialize and call SLEEP_UNTIL_MESSAGE_OR_HEIGHT + BlockUtils.mintBlock(repository); // height now 3 + + // Fetch AT's balance for this height + long preMintBalance = atAccount.getConfirmedBalance(Asset.QORT); + + // Mint several blocks + for (int i = 3; i < WAKE_HEIGHT; ++i) + BlockUtils.mintBlock(repository); + + // We should now be at WAKE_HEIGHT + long height = repository.getBlockRepository().getBlockchainHeight(); + assertEquals(WAKE_HEIGHT, height); + + // AT should have woken and run at this height so balance should have changed + + // Fetch new AT balance + long postMintBalance = atAccount.getConfirmedBalance(Asset.QORT); + + assertNotSame(preMintBalance, postMintBalance); + + // Confirm AT has no message + extractNextTxTimestamp(repository, atAddress, rawNextTimestamp); + assertArrayEquals(new byte[32], rawNextTimestamp); + + // Mint yet another block + BlockUtils.mintBlock(repository); + + // AT should also have woken and run at this height so balance should have changed + + // Fetch new AT balance + long postMint2Balance = atAccount.getConfirmedBalance(Asset.QORT); + + assertNotSame(postMintBalance, postMint2Balance); + + // Confirm AT still has no message + extractNextTxTimestamp(repository, atAddress, rawNextTimestamp); + assertArrayEquals(new byte[32], rawNextTimestamp); + + } + + private static byte[] buildSleepUntilMessageOrHeightAT() { + // Labels for data segment addresses + int addrCounter = 0; + + // Beginning of data segment for easy extraction + final int addrNextTx = addrCounter; + addrCounter += 4; + + final int addrNextTxIndex = addrCounter++; + + final int addrLastTxTimestamp = addrCounter++; + + final int addrWakeHeight = addrCounter++; + + // Data segment + ByteBuffer dataByteBuffer = ByteBuffer.allocate(addrCounter * MachineState.VALUE_SIZE); + + // skip addrNextTx + dataByteBuffer.position(dataByteBuffer.position() + 4 * MachineState.VALUE_SIZE); + + // Store pointer to addrNextTx at addrNextTxIndex + dataByteBuffer.putLong(addrNextTx); + + // skip addrLastTxTimestamp + dataByteBuffer.position(dataByteBuffer.position() + MachineState.VALUE_SIZE); + + // Store fixed wake height (block 10) + dataByteBuffer.putLong(WAKE_HEIGHT); + + ByteBuffer codeByteBuffer = ByteBuffer.allocate(512); + + // Two-pass version + for (int pass = 0; pass < 2; ++pass) { + codeByteBuffer.clear(); + + try { + /* Initialization */ + + // Use AT creation 'timestamp' as starting point for finding transactions sent to AT + codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_CREATION_TIMESTAMP, addrLastTxTimestamp)); + + // Set restart position to after this opcode + codeByteBuffer.put(OpCode.SET_PCS.compile()); + + /* Loop, waiting for message to AT */ + + /* Sleep until message arrives */ + codeByteBuffer.put(OpCode.EXT_FUN_DAT_2.compile(QortalFunctionCode.SLEEP_UNTIL_MESSAGE_OR_HEIGHT.value, addrLastTxTimestamp, addrWakeHeight)); + + // Find next transaction to this AT since the last one (if any) + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.PUT_TX_AFTER_TIMESTAMP_INTO_A, addrLastTxTimestamp)); + + // Copy A to data segment, starting at addrNextTx (as pointed to by addrNextTxIndex) + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_A_IND, addrNextTxIndex)); + + // Stop if timestamp part of A is zero + codeByteBuffer.put(OpCode.STZ_DAT.compile(addrNextTx)); + + // Update our 'last found transaction's timestamp' using 'timestamp' from transaction + codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_TIMESTAMP_FROM_TX_IN_A, addrLastTxTimestamp)); + + // We're done + codeByteBuffer.put(OpCode.FIN_IMD.compile()); + + } catch (CompilationException e) { + throw new IllegalStateException("Unable to compile AT?", e); + } + } + + codeByteBuffer.flip(); + + byte[] codeBytes = new byte[codeByteBuffer.limit()]; + codeByteBuffer.get(codeBytes); + + final short ciyamAtVersion = 2; + final short numCallStackPages = 0; + final short numUserStackPages = 0; + final long minActivationAmount = 0L; + + return MachineState.toCreationBytes(ciyamAtVersion, codeBytes, dataByteBuffer.array(), numCallStackPages, numUserStackPages, minActivationAmount); + } + + private DeployAtTransaction doDeploy(Repository repository, PrivateKeyAccount deployer, byte[] creationBytes, long fundingAmount) throws DataException { + long txTimestamp = System.currentTimeMillis(); + byte[] lastReference = deployer.getLastReference(); + + if (lastReference == null) { + System.err.println(String.format("Qortal account %s has no last reference", deployer.getAddress())); + System.exit(2); + } + + Long fee = null; + String name = "Test AT"; + String description = "Test AT"; + String atType = "Test"; + String tags = "TEST"; + + BaseTransactionData baseTransactionData = new BaseTransactionData(txTimestamp, Group.NO_GROUP, lastReference, deployer.getPublicKey(), fee, null); + TransactionData deployAtTransactionData = new DeployAtTransactionData(baseTransactionData, name, description, atType, tags, creationBytes, fundingAmount, Asset.QORT); + + DeployAtTransaction deployAtTransaction = new DeployAtTransaction(repository, deployAtTransactionData); + + fee = deployAtTransaction.calcRecommendedFee(); + deployAtTransactionData.setFee(fee); + + TransactionUtils.signAndMint(repository, deployAtTransactionData, deployer); + + return deployAtTransaction; + } + + private void extractNextTxTimestamp(Repository repository, String atAddress, byte[] rawNextTimestamp) throws DataException { + // Check AT result + ATStateData atStateData = repository.getATRepository().getLatestATState(atAddress); + byte[] stateData = atStateData.getStateData(); + + byte[] dataBytes = MachineState.extractDataBytes(stateData); + + System.arraycopy(dataBytes, 0, rawNextTimestamp, 0, rawNextTimestamp.length); + } + + private MessageTransaction sendMessage(Repository repository, PrivateKeyAccount sender, byte[] data, String recipient) throws DataException { + long txTimestamp = System.currentTimeMillis(); + byte[] lastReference = sender.getLastReference(); + + if (lastReference == null) { + System.err.println(String.format("Qortal account %s has no last reference", sender.getAddress())); + System.exit(2); + } + + Long fee = null; + int version = 4; + int nonce = 0; + long amount = 0; + Long assetId = null; // because amount is zero + + BaseTransactionData baseTransactionData = new BaseTransactionData(txTimestamp, Group.NO_GROUP, lastReference, sender.getPublicKey(), fee, null); + TransactionData messageTransactionData = new MessageTransactionData(baseTransactionData, version, nonce, recipient, amount, assetId, data, false, false); + + MessageTransaction messageTransaction = new MessageTransaction(repository, messageTransactionData); + + fee = messageTransaction.calcRecommendedFee(); + messageTransactionData.setFee(fee); + + TransactionUtils.signAndImportValid(repository, messageTransactionData, sender); + + return messageTransaction; + } + + private void assertTimestamp(Repository repository, String atAddress, Transaction transaction) throws DataException { + int height = transaction.getHeight(); + byte[] transactionSignature = transaction.getTransactionData().getSignature(); + + BlockData blockData = repository.getBlockRepository().fromHeight(height); + assertNotNull(blockData); + + Block block = new Block(repository, blockData); + + List blockTransactions = block.getTransactions(); + int sequence; + for (sequence = blockTransactions.size() - 1; sequence >= 0; --sequence) + if (Arrays.equals(blockTransactions.get(sequence).getTransactionData().getSignature(), transactionSignature)) + break; + + assertNotSame(-1, sequence); + + byte[] rawNextTimestamp = new byte[32]; + extractNextTxTimestamp(repository, atAddress, rawNextTimestamp); + + Timestamp expectedTimestamp = new Timestamp(height, sequence); + Timestamp actualTimestamp = new Timestamp(BitTwiddling.longFromBEBytes(rawNextTimestamp, 0)); + + assertEquals(String.format("Expected height %d, seq %d but was height %d, seq %d", + height, sequence, + actualTimestamp.blockHeight, actualTimestamp.transactionSequence + ), + expectedTimestamp.longValue(), + actualTimestamp.longValue()); + + byte[] expectedPartialSignature = new byte[24]; + System.arraycopy(transactionSignature, 8, expectedPartialSignature, 0, expectedPartialSignature.length); + + byte[] actualPartialSignature = new byte[24]; + System.arraycopy(rawNextTimestamp, 8, actualPartialSignature, 0, actualPartialSignature.length); + + assertArrayEquals(expectedPartialSignature, actualPartialSignature); + } + +} diff --git a/src/test/java/org/qortal/test/at/SleepUntilMessageTests.java b/src/test/java/org/qortal/test/at/SleepUntilMessageTests.java new file mode 100644 index 00000000..290f973a --- /dev/null +++ b/src/test/java/org/qortal/test/at/SleepUntilMessageTests.java @@ -0,0 +1,311 @@ +package org.qortal.test.at; + +import static org.junit.Assert.*; + +import java.nio.ByteBuffer; +import java.util.Arrays; +import java.util.List; + +import org.ciyam.at.CompilationException; +import org.ciyam.at.FunctionCode; +import org.ciyam.at.MachineState; +import org.ciyam.at.OpCode; +import org.ciyam.at.Timestamp; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.qortal.account.Account; +import org.qortal.account.PrivateKeyAccount; +import org.qortal.asset.Asset; +import org.qortal.at.QortalFunctionCode; +import org.qortal.block.Block; +import org.qortal.data.at.ATStateData; +import org.qortal.data.block.BlockData; +import org.qortal.data.transaction.BaseTransactionData; +import org.qortal.data.transaction.DeployAtTransactionData; +import org.qortal.data.transaction.MessageTransactionData; +import org.qortal.data.transaction.TransactionData; +import org.qortal.group.Group; +import org.qortal.repository.DataException; +import org.qortal.repository.Repository; +import org.qortal.repository.RepositoryManager; +import org.qortal.test.common.BlockUtils; +import org.qortal.test.common.Common; +import org.qortal.test.common.TransactionUtils; +import org.qortal.transaction.DeployAtTransaction; +import org.qortal.transaction.MessageTransaction; +import org.qortal.transaction.Transaction; +import org.qortal.utils.BitTwiddling; + +public class SleepUntilMessageTests extends Common { + + private static final byte[] messageData = new byte[] { 0x44 }; + private static final byte[] creationBytes = buildSleepUntilMessageAT(); + private static final long fundingAmount = 1_00000000L; + + private Repository repository = null; + private PrivateKeyAccount deployer; + private DeployAtTransaction deployAtTransaction; + private Account atAccount; + private String atAddress; + private byte[] rawNextTimestamp = new byte[32]; + private Transaction transaction; + + @Before + public void before() throws DataException { + Common.useDefaultSettings(); + + this.repository = RepositoryManager.getRepository(); + this.deployer = Common.getTestAccount(repository, "alice"); + + this.deployAtTransaction = doDeploy(repository, deployer, creationBytes, fundingAmount); + this.atAccount = deployAtTransaction.getATAccount(); + this.atAddress = deployAtTransaction.getATAccount().getAddress(); + } + + @After + public void after() throws DataException { + if (this.repository != null) + this.repository.close(); + + this.repository = null; + } + + @Test + public void testDeploy() throws DataException { + // Confirm initial value is zero + extractNextTxTimestamp(repository, atAddress, rawNextTimestamp); + assertArrayEquals(new byte[32], rawNextTimestamp); + } + + @Test + public void testFeelessSleep() throws DataException { + // Mint block to allow AT to initialize and call SLEEP_UNTIL_MESSAGE + BlockUtils.mintBlock(repository); + + // Fetch AT's balance for this height + long preMintBalance = atAccount.getConfirmedBalance(Asset.QORT); + + // Mint block + BlockUtils.mintBlock(repository); + + // Fetch new AT balance + long postMintBalance = atAccount.getConfirmedBalance(Asset.QORT); + + assertEquals(preMintBalance, postMintBalance); + } + + @Test + public void testFeelessSleep2() throws DataException { + // Mint block to allow AT to initialize and call SLEEP_UNTIL_MESSAGE + BlockUtils.mintBlock(repository); + + // Fetch AT's balance for this height + long preMintBalance = atAccount.getConfirmedBalance(Asset.QORT); + + // Mint several blocks + for (int i = 0; i < 10; ++i) + BlockUtils.mintBlock(repository); + + // Fetch new AT balance + long postMintBalance = atAccount.getConfirmedBalance(Asset.QORT); + + assertEquals(preMintBalance, postMintBalance); + } + + @Test + public void testSleepUntilMessage() throws DataException { + // Mint block to allow AT to initialize and call SLEEP_UNTIL_MESSAGE + BlockUtils.mintBlock(repository); + + // Send message to AT + transaction = sendMessage(repository, deployer, messageData, atAddress); + BlockUtils.mintBlock(repository); + + // Mint block so AT executes and finds message + BlockUtils.mintBlock(repository); + + // Confirm AT finds message + assertTimestamp(repository, atAddress, transaction); + } + + private static byte[] buildSleepUntilMessageAT() { + // Labels for data segment addresses + int addrCounter = 0; + + // Beginning of data segment for easy extraction + final int addrNextTx = addrCounter; + addrCounter += 4; + + final int addrNextTxIndex = addrCounter++; + + final int addrLastTxTimestamp = addrCounter++; + + // Data segment + ByteBuffer dataByteBuffer = ByteBuffer.allocate(addrCounter * MachineState.VALUE_SIZE); + + // skip addrNextTx + dataByteBuffer.position(dataByteBuffer.position() + 4 * MachineState.VALUE_SIZE); + + // Store pointer to addrNextTx at addrNextTxIndex + dataByteBuffer.putLong(addrNextTx); + + ByteBuffer codeByteBuffer = ByteBuffer.allocate(512); + + // Two-pass version + for (int pass = 0; pass < 2; ++pass) { + codeByteBuffer.clear(); + + try { + /* Initialization */ + + // Use AT creation 'timestamp' as starting point for finding transactions sent to AT + codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_CREATION_TIMESTAMP, addrLastTxTimestamp)); + + // Set restart position to after this opcode + codeByteBuffer.put(OpCode.SET_PCS.compile()); + + /* Loop, waiting for message to AT */ + + /* Sleep until message arrives */ + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(QortalFunctionCode.SLEEP_UNTIL_MESSAGE.value, addrLastTxTimestamp)); + + // Find next transaction to this AT since the last one (if any) + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.PUT_TX_AFTER_TIMESTAMP_INTO_A, addrLastTxTimestamp)); + + // Copy A to data segment, starting at addrNextTx (as pointed to by addrNextTxIndex) + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_A_IND, addrNextTxIndex)); + + // Stop if timestamp part of A is zero + codeByteBuffer.put(OpCode.STZ_DAT.compile(addrNextTx)); + + // Update our 'last found transaction's timestamp' using 'timestamp' from transaction + codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_TIMESTAMP_FROM_TX_IN_A, addrLastTxTimestamp)); + + // We're done + codeByteBuffer.put(OpCode.FIN_IMD.compile()); + + } catch (CompilationException e) { + throw new IllegalStateException("Unable to compile AT?", e); + } + } + + codeByteBuffer.flip(); + + byte[] codeBytes = new byte[codeByteBuffer.limit()]; + codeByteBuffer.get(codeBytes); + + final short ciyamAtVersion = 2; + final short numCallStackPages = 0; + final short numUserStackPages = 0; + final long minActivationAmount = 0L; + + return MachineState.toCreationBytes(ciyamAtVersion, codeBytes, dataByteBuffer.array(), numCallStackPages, numUserStackPages, minActivationAmount); + } + + private DeployAtTransaction doDeploy(Repository repository, PrivateKeyAccount deployer, byte[] creationBytes, long fundingAmount) throws DataException { + long txTimestamp = System.currentTimeMillis(); + byte[] lastReference = deployer.getLastReference(); + + if (lastReference == null) { + System.err.println(String.format("Qortal account %s has no last reference", deployer.getAddress())); + System.exit(2); + } + + Long fee = null; + String name = "Test AT"; + String description = "Test AT"; + String atType = "Test"; + String tags = "TEST"; + + BaseTransactionData baseTransactionData = new BaseTransactionData(txTimestamp, Group.NO_GROUP, lastReference, deployer.getPublicKey(), fee, null); + TransactionData deployAtTransactionData = new DeployAtTransactionData(baseTransactionData, name, description, atType, tags, creationBytes, fundingAmount, Asset.QORT); + + DeployAtTransaction deployAtTransaction = new DeployAtTransaction(repository, deployAtTransactionData); + + fee = deployAtTransaction.calcRecommendedFee(); + deployAtTransactionData.setFee(fee); + + TransactionUtils.signAndMint(repository, deployAtTransactionData, deployer); + + return deployAtTransaction; + } + + private void extractNextTxTimestamp(Repository repository, String atAddress, byte[] rawNextTimestamp) throws DataException { + // Check AT result + ATStateData atStateData = repository.getATRepository().getLatestATState(atAddress); + byte[] stateData = atStateData.getStateData(); + + byte[] dataBytes = MachineState.extractDataBytes(stateData); + + System.arraycopy(dataBytes, 0, rawNextTimestamp, 0, rawNextTimestamp.length); + } + + private MessageTransaction sendMessage(Repository repository, PrivateKeyAccount sender, byte[] data, String recipient) throws DataException { + long txTimestamp = System.currentTimeMillis(); + byte[] lastReference = sender.getLastReference(); + + if (lastReference == null) { + System.err.println(String.format("Qortal account %s has no last reference", sender.getAddress())); + System.exit(2); + } + + Long fee = null; + int version = 4; + int nonce = 0; + long amount = 0; + Long assetId = null; // because amount is zero + + BaseTransactionData baseTransactionData = new BaseTransactionData(txTimestamp, Group.NO_GROUP, lastReference, sender.getPublicKey(), fee, null); + TransactionData messageTransactionData = new MessageTransactionData(baseTransactionData, version, nonce, recipient, amount, assetId, data, false, false); + + MessageTransaction messageTransaction = new MessageTransaction(repository, messageTransactionData); + + fee = messageTransaction.calcRecommendedFee(); + messageTransactionData.setFee(fee); + + TransactionUtils.signAndImportValid(repository, messageTransactionData, sender); + + return messageTransaction; + } + + private void assertTimestamp(Repository repository, String atAddress, Transaction transaction) throws DataException { + int height = transaction.getHeight(); + byte[] transactionSignature = transaction.getTransactionData().getSignature(); + + BlockData blockData = repository.getBlockRepository().fromHeight(height); + assertNotNull(blockData); + + Block block = new Block(repository, blockData); + + List blockTransactions = block.getTransactions(); + int sequence; + for (sequence = blockTransactions.size() - 1; sequence >= 0; --sequence) + if (Arrays.equals(blockTransactions.get(sequence).getTransactionData().getSignature(), transactionSignature)) + break; + + assertNotSame(-1, sequence); + + byte[] rawNextTimestamp = new byte[32]; + extractNextTxTimestamp(repository, atAddress, rawNextTimestamp); + + Timestamp expectedTimestamp = new Timestamp(height, sequence); + Timestamp actualTimestamp = new Timestamp(BitTwiddling.longFromBEBytes(rawNextTimestamp, 0)); + + assertEquals(String.format("Expected height %d, seq %d but was height %d, seq %d", + height, sequence, + actualTimestamp.blockHeight, actualTimestamp.transactionSequence + ), + expectedTimestamp.longValue(), + actualTimestamp.longValue()); + + byte[] expectedPartialSignature = new byte[24]; + System.arraycopy(transactionSignature, 8, expectedPartialSignature, 0, expectedPartialSignature.length); + + byte[] actualPartialSignature = new byte[24]; + System.arraycopy(rawNextTimestamp, 8, actualPartialSignature, 0, actualPartialSignature.length); + + assertArrayEquals(expectedPartialSignature, actualPartialSignature); + } + +}