diff --git a/src/main/java/com/algorand/algosdk/builder/transaction/ApplicationBaseTransactionBuilder.java b/src/main/java/com/algorand/algosdk/builder/transaction/ApplicationBaseTransactionBuilder.java index 489806a6b..d66b10e36 100644 --- a/src/main/java/com/algorand/algosdk/builder/transaction/ApplicationBaseTransactionBuilder.java +++ b/src/main/java/com/algorand/algosdk/builder/transaction/ApplicationBaseTransactionBuilder.java @@ -49,6 +49,7 @@ public LocalsReference(Address address, long appId) { private List holdings; private List locals; private Long applicationId; + private Long rejectVersion; private boolean useAccess = false; /** @@ -129,6 +130,7 @@ protected void applyTo(Transaction txn) { if (applicationId != null) txn.applicationId = applicationId; if (onCompletion != null) txn.onCompletion = onCompletion; if (applicationArgs != null) txn.applicationArgs = applicationArgs; + if (rejectVersion != null) txn.rejectVersion = rejectVersion; } @Override @@ -224,23 +226,23 @@ public T locals(List locals) { /** * Enable or disable translation of foreign references into the access field. - * + * * When useAccess=true: * - All foreign references (accounts, foreignApps, foreignAssets, boxReferences) are translated * into a unified access field instead of using separate legacy fields * - You can still use the same methods (accounts(), foreignApps(), etc.) - they will be translated * - Advanced features (holdings(), locals()) are also available * - Compatible with networks that support the access field consensus upgrade - * + * * When useAccess=false (default): * - Uses legacy separate fields (accounts, foreignApps, foreignAssets, boxReferences) * - No translation occurs - references are placed directly in their respective fields * - Maintains backward compatibility with pre-consensus upgrade networks * - Advanced features (holdings(), locals()) are not allowed - * + * * This design allows easy migration - just add .useAccess(true) to enable access field mode * while keeping your existing foreign reference method calls. - * + * * @param useAccess true to translate references to access field, false to use legacy fields * @return this builder instance */ @@ -248,4 +250,17 @@ public T useAccess(boolean useAccess) { this.useAccess = useAccess; return (T) this; } + + /** + * Set the reject version for the application call. + * The lowest application version for which this transaction should immediately fail. + * 0 indicates that no version check should be performed. + * + * @param rejectVersion the minimum application version to reject + * @return this builder instance + */ + public T rejectVersion(Long rejectVersion) { + this.rejectVersion = rejectVersion; + return (T) this; + } } diff --git a/src/main/java/com/algorand/algosdk/transaction/Transaction.java b/src/main/java/com/algorand/algosdk/transaction/Transaction.java index 16c12bacf..e71a6ed0e 100644 --- a/src/main/java/com/algorand/algosdk/transaction/Transaction.java +++ b/src/main/java/com/algorand/algosdk/transaction/Transaction.java @@ -165,6 +165,9 @@ public class Transaction implements Serializable { @JsonProperty("apep") public Long extraPages = 0L; + @JsonProperty("aprv") + public Long rejectVersion = 0L; + /* access field - unifies accounts, foreignApps, foreignAssets, and boxReferences */ @JsonProperty("al") public List access = new ArrayList<>(); @@ -246,6 +249,7 @@ private Transaction(@JsonProperty("type") Type type, @JsonProperty("apls") StateSchema localStateSchema, @JsonProperty("apsu") byte[] clearStateProgram, @JsonProperty("apep") Long extraPages, + @JsonProperty("aprv") Long rejectVersion, // access fields @JsonProperty("al") List access, // heartbeat fields @@ -301,6 +305,7 @@ private Transaction(@JsonProperty("type") Type type, localStateSchema, clearStateProgram == null ? null : new TEALProgram(clearStateProgram), extraPages, + rejectVersion, access == null ? new ArrayList<>() : access, heartbeatFields ); @@ -362,6 +367,7 @@ private Transaction( StateSchema localStateSchema, TEALProgram clearStateProgram, Long extraPages, + Long rejectVersion, List access, HeartbeatTxnFields heartbeatFields ) { @@ -408,6 +414,7 @@ private Transaction( if (localStateSchema != null) this.localStateSchema = localStateSchema; if (clearStateProgram != null) this.clearStateProgram = clearStateProgram; if (extraPages != null) this.extraPages = extraPages; + if (rejectVersion != null) this.rejectVersion = rejectVersion; if (access != null) this.access = access; if (heartbeatFields != null) this.heartbeatFields = heartbeatFields; } @@ -622,6 +629,7 @@ public boolean equals(Object o) { freezeState == that.freezeState && rekeyTo.equals(that.rekeyTo) && extraPages.equals(that.extraPages) && + rejectVersion.equals(that.rejectVersion) && boxReferences.equals(that.boxReferences) && heartbeatFields.equals(that.heartbeatFields); } diff --git a/src/test/java/com/algorand/algosdk/transaction/TestTransaction.java b/src/test/java/com/algorand/algosdk/transaction/TestTransaction.java index e21f67804..70ba9965b 100644 --- a/src/test/java/com/algorand/algosdk/transaction/TestTransaction.java +++ b/src/test/java/com/algorand/algosdk/transaction/TestTransaction.java @@ -1053,4 +1053,72 @@ public void TxnEstimatedDefaultFee() throws Exception { } } + @Test + public void testApplicationCallRejectVersion() throws Exception { + Address from = new Address("VKM6KSCTDHEM6KGEAMSYCNEGIPFJMHDSEMIRAQLK76CJDIRMMDHKAIRMFQ"); + byte[] gh = Encoder.decodeFromBase64("SGO1GKSzyE7IEPItTxCByw9x8FmnrCDexi9/cOUJOiI="); + + // Test with specific rejectVersion + Transaction txWithReject = Transaction.ApplicationCallTransactionBuilder() + .sender(from) + .applicationId(123L) + .firstValid(1000) + .lastValid(2000) + .genesisHash(gh) + .rejectVersion(5L) + .build(); + + // Verify rejectVersion is set + assertThat(txWithReject.rejectVersion).isEqualTo(5L); + + // Test serialization/deserialization with msgpack + byte[] encodedTxn = Encoder.encodeToMsgPack(txWithReject); + Transaction decodedTxn = Encoder.decodeFromMsgPack(encodedTxn, Transaction.class); + assertThat(decodedTxn.rejectVersion).isEqualTo(5L); + assertEqual(txWithReject, decodedTxn); + + // Test serialization/deserialization with JSON + ObjectMapper objectMapper = new ObjectMapper(); + String transactionJson = objectMapper.writeValueAsString(txWithReject); + Transaction jsonDecodedTxn = objectMapper.readValue(transactionJson, Transaction.class); + assertThat(jsonDecodedTxn.rejectVersion).isEqualTo(5L); + assertEqual(txWithReject, jsonDecodedTxn); + + // Verify aprv field is present in JSON when rejectVersion is non-zero + assertThat(transactionJson).contains("\"aprv\":5"); + + // Test with default rejectVersion (should be 0) + Transaction txDefault = Transaction.ApplicationCallTransactionBuilder() + .sender(from) + .applicationId(456L) + .firstValid(1000) + .lastValid(2000) + .genesisHash(gh) + .build(); + + // Verify default rejectVersion is 0 + assertThat(txDefault.rejectVersion).isEqualTo(0L); + + // Verify aprv field is NOT present in JSON when rejectVersion is 0 (default omission) + String txDefaultJson = objectMapper.writeValueAsString(txDefault); + assertThat(txDefaultJson).doesNotContain("aprv"); + + // Test explicit 0 rejectVersion + Transaction txExplicitZero = Transaction.ApplicationCallTransactionBuilder() + .sender(from) + .applicationId(456L) + .firstValid(1000) + .lastValid(2000) + .genesisHash(gh) + .rejectVersion(0L) + .build(); + + assertThat(txExplicitZero.rejectVersion).isEqualTo(0L); + assertEqual(txDefault, txExplicitZero); + + // Verify aprv field is NOT present in JSON when rejectVersion is explicitly 0 + String txExplicitZeroJson = objectMapper.writeValueAsString(txExplicitZero); + assertThat(txExplicitZeroJson).doesNotContain("aprv"); + } + }