diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..f2f82b746 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,100 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Build and Development Commands + +### Build +```bash +mvn package +``` + +### Testing +```bash +# Run unit tests only +make unit +# or: mvn test -Dcucumber.filter.tags="@unit" + +# Run integration tests only +make integration +# or: mvn test -Dtest=com.algorand.algosdk.integration.RunCucumberIntegrationTest -Dcucumber.filter.tags="@integration" + +# Run all tests (unit + integration) +make ci-test + +# Run tests with Docker test harness +make docker-test + +# Start/stop test harness manually +make harness +make harness-down +``` + +### Examples +The `examples/` directory contains example code: +```bash +cd examples/ +mvn package +java -cp target/sdk-extras-1.0-SNAPSHOT.jar com.algorand.examples.Example +``` + +## Architecture Overview + +This is the official Java SDK for Algorand blockchain, providing client libraries for interacting with algod and indexer nodes. + +### Core Package Structure + +- **`account/`** - Account management, keypair generation, and logic signature accounts +- **`crypto/`** - Core cryptographic primitives (Ed25519, addresses, signatures, multisig) +- **`transaction/`** - Transaction types, builders, and atomic transaction composer +- **`v2/client/`** - REST clients for algod and indexer APIs (auto-generated from OpenAPI specs) +- **`builder/transaction/`** - Fluent transaction builders for all transaction types +- **`abi/`** - Application Binary Interface support for smart contracts +- **`mnemonic/`** - BIP39 mnemonic phrase utilities +- **`util/`** - Encoding/decoding utilities (MessagePack, Base64, etc.) + +### Key Components + +#### Transaction System +- **Transaction Builder Pattern**: All transaction types use fluent builders (e.g., `PaymentTransactionBuilder`, `ApplicationCallTransactionBuilder`) +- **Atomic Transaction Composer**: High-level interface for building transaction groups with ABI method calls +- **Transaction Types**: Payment, asset transfer, application calls, key registration, state proof, heartbeat + +#### Client Architecture +- **AlgodClient**: REST client for algod daemon (node operations, transaction submission) +- **IndexerClient**: REST client for indexer service (historical queries, account lookups) +- **Generated Code**: Most v2 client classes are auto-generated from OpenAPI specifications + +#### Cryptography +- **Ed25519**: Primary signature algorithm using BouncyCastle +- **Multisig**: M-of-N threshold signatures +- **Logic Signatures**: Delegated signing using TEAL programs +- **Address**: 32-byte public key hash with checksum + +## Code Generation + +The v2 client code (`v2.client.algod.*`, `v2.client.indexer.*`, `v2.client.model.*`) is generated from OpenAPI specs: +- algod.oas2.json - algod daemon API +- indexer.oas2.json - indexer service API + +To regenerate clients, use the `generate_java.sh` script from the [generator](https://github.com/algorand/generator/) repository. + +## Testing Framework + +Uses Cucumber BDD testing with separate unit and integration test suites: +- **Unit tests**: Fast tests using mocked clients (`@unit` tags) +- **Integration tests**: Full integration with test harness (`@integration` tags) +- **Test harness**: Dockerized Algorand network for integration testing + +Test files are organized under: +- `src/test/java/com/algorand/algosdk/unit/` - Unit test step definitions +- `src/test/java/com/algorand/algosdk/integration/` - Integration test step definitions +- `src/test/resources/` - Test data and response fixtures +- `test-harness/features/` - Cucumber feature files + +## Development Notes + +- **Java 8+ Compatibility**: Maintains Java 8 compatibility with Android support (minSdkVersion 26+) +- **Dependencies**: Uses Jackson for JSON, MessagePack for serialization, BouncyCastle for crypto +- **Maven Profiles**: Includes IDE profile for IntelliJ compatibility with mixed Java versions +- **Generated Content**: Do not manually edit generated client code - regenerate from specs instead \ No newline at end of file diff --git a/README.md b/README.md index e4eddbe27..16e8c12d2 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,5 @@ # java-algorand-sdk -[![CircleCI](https://dl.circleci.com/status-badge/img/gh/algorand/java-algorand-sdk/tree/develop.svg?style=svg)](https://dl.circleci.com/status-badge/redirect/gh/algorand/java-algorand-sdk/tree/develop) diff --git a/examples/pom.xml b/examples/pom.xml index 21bec1b2f..da9c8b0d3 100644 --- a/examples/pom.xml +++ b/examples/pom.xml @@ -96,7 +96,7 @@ com.algorand algosdk - 2.0.0 + 2.9.0 diff --git a/examples/src/main/java/com/algorand/algosdk/example/UseAccessFlagExample.java b/examples/src/main/java/com/algorand/algosdk/example/UseAccessFlagExample.java new file mode 100644 index 000000000..a6a888a75 --- /dev/null +++ b/examples/src/main/java/com/algorand/algosdk/example/UseAccessFlagExample.java @@ -0,0 +1,135 @@ +package com.algorand.algosdk.example; + +import com.algorand.algosdk.account.Account; +import com.algorand.algosdk.builder.transaction.ApplicationCallTransactionBuilder; +import com.algorand.algosdk.builder.transaction.ApplicationBaseTransactionBuilder; +import com.algorand.algosdk.crypto.Address; +import com.algorand.algosdk.transaction.AppBoxReference; +import com.algorand.algosdk.transaction.ResourceRef; +import com.algorand.algosdk.transaction.Transaction; +import com.algorand.algosdk.util.Encoder; + +import java.math.BigInteger; +import java.util.Arrays; +import java.util.List; + +/** + * Demonstrates the useAccess flag for handling consensus upgrade compatibility. + * + * The useAccess flag controls how foreign references are handled: + * - useAccess=false: Uses legacy fields (accounts, foreignApps, foreignAssets, boxReferences) + * - useAccess=true: Translates the same references into a unified access field + * + * Key insight: You use the SAME builder methods in both modes - just add .useAccess(true)! + * No API changes needed for migration, making upgrades simple and safe. + */ +public class UseAccessFlagExample { + + public static void main(String[] args) throws Exception { + Account sender = new Account(); + Account otherAccount = new Account(); + + System.out.println("=== useAccess Flag Examples ===\n"); + System.out.println("Shows how easy it is to migrate to access field mode:"); + System.out.println("Just add .useAccess(true) - no other code changes needed!\n"); + + // Example 1: Legacy mode (useAccess=false, default) + demonstrateLegacyMode(sender, otherAccount); + + System.out.println("\n" + "=".repeat(50) + "\n"); + + // Example 2: Same code with useAccess=true - easy migration! + demonstrateEasyMigration(sender, otherAccount); + + System.out.println("\n" + "=".repeat(50) + "\n"); + + System.out.println("\nšŸŽ‰ That's it! Migration to access field mode is just one line."); + System.out.println("Advanced features like holdings() and locals() are also available with useAccess=true."); + } + + private static void demonstrateLegacyMode(Account sender, Account otherAccount) throws Exception { + System.out.println("Example 1: Legacy Mode (useAccess=false)"); + System.out.println("-----------------------------------------"); + System.out.println("Using standard foreign reference methods with legacy field output"); + + // Build transaction using foreign reference methods (default behavior) + Transaction txn = ApplicationCallTransactionBuilder.Builder() + .sender(sender.getAddress()) + .applicationId(12345L) + .useAccess(false) // Default mode - puts references in separate fields + .accounts(Arrays.asList(otherAccount.getAddress())) + .foreignApps(Arrays.asList(67890L)) + .foreignAssets(Arrays.asList(999L)) + .firstValid(BigInteger.valueOf(1000)) + .lastValid(BigInteger.valueOf(2000)) + .genesisHash(Encoder.decodeFromBase64("SGO1GKSzyE7IEPItTxCByw9x8FmnrCDexi9/cOUJOiI=")) + .build(); + + // Show how references are stored in separate legacy fields + System.out.printf("Result: References stored in separate fields%n"); + System.out.printf(" - accounts: %d entries%n", txn.accounts.size()); + System.out.printf(" - foreignApps: %d entries%n", txn.foreignApps.size()); + System.out.printf(" - foreignAssets: %d entries%n", txn.foreignAssets.size()); + System.out.printf(" - access field: %d entries (empty)%n", txn.access.size()); + System.out.println("āœ“ Compatible with pre-consensus upgrade networks"); + } + + private static void demonstrateEasyMigration(Account sender, Account otherAccount) throws Exception { + System.out.println("Example 2: Easy Migration (useAccess=true)"); + System.out.println("-------------------------------------------"); + System.out.println("SAME CODE as Example 1 - just add .useAccess(true)!"); + + // Build transaction using the EXACT SAME builder method calls as Example 1 + // The only difference is useAccess=true, which translates these into access field + Transaction txn = ApplicationCallTransactionBuilder.Builder() + .sender(sender.getAddress()) + .applicationId(12345L) + .accounts(Arrays.asList(otherAccount.getAddress())) // Same as Example 1 + .foreignApps(Arrays.asList(67890L)) // Same as Example 1 + .foreignAssets(Arrays.asList(999L)) // Same as Example 1 + .firstValid(BigInteger.valueOf(1000)) + .lastValid(BigInteger.valueOf(2000)) + .genesisHash(Encoder.decodeFromBase64("SGO1GKSzyE7IEPItTxCByw9x8FmnrCDexi9/cOUJOiI=")) + .useAccess(true) // šŸ”„ ONLY CHANGE: Add this one line! + .build(); + + // Show how the same references are now translated to access field + System.out.printf("Result: Same references, different internal format%n"); + System.out.printf(" - accounts: %d entries (empty - translated to access)%n", txn.accounts.size()); + System.out.printf(" - foreignApps: %d entries (empty - translated to access)%n", txn.foreignApps.size()); + System.out.printf(" - foreignAssets: %d entries (empty - translated to access)%n", txn.foreignAssets.size()); + System.out.printf(" - access field: %d entries (contains all references)%n", txn.access.size()); + + System.out.println("\nšŸš€ Migration is just one line: .useAccess(true)"); + System.out.println("āœ“ No API changes required"); + System.out.println("āœ“ Same builder methods work in both modes"); + System.out.println("āœ“ Ready for post-consensus upgrade networks"); + } + + + + private static String formatAccessEntry(ResourceRef ref) { + if (ref.address != null) { + return "Address: " + ref.address.toString().substring(0, 10) + "..."; + } + if (ref.asset != null) { + return "Asset: " + ref.asset; + } + if (ref.app != null) { + return "App: " + ref.app; + } + if (ref.holding != null) { + return String.format("Holding: addr_idx=%d, asset_idx=%d", + ref.holding.addressIndex, ref.holding.assetIndex); + } + if (ref.locals != null) { + return String.format("Locals: addr_idx=%d, app_idx=%d", + ref.locals.addressIndex, ref.locals.appIndex); + } + if (ref.box != null) { + return String.format("Box: app_idx=%d, name='%s'", + ref.box.index, new String(ref.box.name)); + } + return "Empty"; + } +} \ No newline at end of file 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 a0d4fb496..489806a6b 100644 --- a/src/main/java/com/algorand/algosdk/builder/transaction/ApplicationBaseTransactionBuilder.java +++ b/src/main/java/com/algorand/algosdk/builder/transaction/ApplicationBaseTransactionBuilder.java @@ -1,7 +1,10 @@ package com.algorand.algosdk.builder.transaction; import com.algorand.algosdk.crypto.Address; +import com.algorand.algosdk.transaction.AccessConverter; import com.algorand.algosdk.transaction.AppBoxReference; +import com.algorand.algosdk.transaction.AppResourceRef; +import com.algorand.algosdk.transaction.ResourceRef; import com.algorand.algosdk.transaction.Transaction; import com.algorand.algosdk.util.Encoder; @@ -11,13 +14,42 @@ @SuppressWarnings("unchecked") public abstract class ApplicationBaseTransactionBuilder> extends TransactionBuilder implements ApplicationCallReferencesSetter { + + /** + * Represents a holding reference for asset holdings of an account. + */ + public static class HoldingReference { + public final Address address; + public final long assetId; + + public HoldingReference(Address address, long assetId) { + this.address = address; + this.assetId = assetId; + } + } + + /** + * Represents a locals reference for local state of an account in an app. + */ + public static class LocalsReference { + public final Address address; + public final long appId; + + public LocalsReference(Address address, long appId) { + this.address = address; + this.appId = appId; + } + } private Transaction.OnCompletion onCompletion; private List applicationArgs; private List
accounts; private List foreignApps; private List foreignAssets; private List appBoxReferences; + private List holdings; + private List locals; private Long applicationId; + private boolean useAccess = false; /** * All application calls use this type, so no need to make this private. This constructor should always be called. @@ -32,13 +64,71 @@ protected void applyTo(Transaction txn) { Objects.requireNonNull(onCompletion, "OnCompletion is required, please file a bug report."); Objects.requireNonNull(applicationId); + // Check if advanced features are being used + boolean hasAdvancedFeatures = (holdings != null && !holdings.isEmpty()) || + (locals != null && !locals.isEmpty()); + + if (useAccess) { + // Using access field mode - translate all references into access list + List allRefs = new ArrayList<>(); + + if (accounts != null && !accounts.isEmpty()) { + for (Address account : accounts) { + allRefs.add(AppResourceRef.forAddress(account)); + } + } + + if (foreignApps != null && !foreignApps.isEmpty()) { + for (Long appId : foreignApps) { + allRefs.add(AppResourceRef.forApp(appId)); + } + } + + if (foreignAssets != null && !foreignAssets.isEmpty()) { + for (Long assetId : foreignAssets) { + allRefs.add(AppResourceRef.forAsset(assetId)); + } + } + + if (appBoxReferences != null && !appBoxReferences.isEmpty()) { + for (AppBoxReference boxRef : appBoxReferences) { + allRefs.add(AppResourceRef.forBox(boxRef.getAppId(), boxRef.getName())); + } + } + + if (holdings != null && !holdings.isEmpty()) { + for (HoldingReference holdingRef : holdings) { + allRefs.add(AppResourceRef.forHolding(holdingRef.address, holdingRef.assetId)); + } + } + + if (locals != null && !locals.isEmpty()) { + for (LocalsReference localsRef : locals) { + allRefs.add(AppResourceRef.forLocals(localsRef.address, localsRef.appId)); + } + } + + txn.access = AccessConverter.convertToResourceRefs(allRefs, sender, applicationId); + + } else { + // Using legacy fields mode + if (hasAdvancedFeatures) { + throw new IllegalArgumentException( + "Holdings and locals references require useAccess=true as they cannot be represented in legacy transaction format" + ); + } + + // Use legacy fields directly + if (accounts != null) txn.accounts = accounts; + if (foreignApps != null) txn.foreignApps = foreignApps; + if (foreignAssets != null) txn.foreignAssets = foreignAssets; + if (appBoxReferences != null) txn.boxReferences = convertBoxes(appBoxReferences, foreignApps, applicationId); + } + + // Set common fields if (applicationId != null) txn.applicationId = applicationId; if (onCompletion != null) txn.onCompletion = onCompletion; if (applicationArgs != null) txn.applicationArgs = applicationArgs; - if (accounts != null) txn.accounts = accounts; - if (foreignApps != null) txn.foreignApps = foreignApps; - if (foreignAssets != null) txn.foreignAssets = foreignAssets; - if (appBoxReferences != null) txn.boxReferences = convertBoxes(appBoxReferences, foreignApps, applicationId); } @Override @@ -107,4 +197,55 @@ public T boxReferences(List boxReferences) { this.appBoxReferences = boxReferences; return (T) this; } + + /** + * Set asset holding references that need to be accessible in this transaction. + * Holdings references allow the transaction to access asset balances of specific accounts. + * + * Note: Holdings references are only available when useAccess=true as they cannot be + * represented in legacy transaction format. + */ + public T holdings(List holdings) { + this.holdings = holdings; + return (T) this; + } + + /** + * Set local state references that need to be accessible in this transaction. + * Locals references allow the transaction to access local state of specific accounts in specific apps. + * + * Note: Locals references are only available when useAccess=true as they cannot be + * represented in legacy transaction format. + */ + public T locals(List locals) { + this.locals = locals; + return (T) this; + } + + /** + * 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 + */ + public T useAccess(boolean useAccess) { + this.useAccess = useAccess; + return (T) this; + } } diff --git a/src/main/java/com/algorand/algosdk/transaction/AccessConverter.java b/src/main/java/com/algorand/algosdk/transaction/AccessConverter.java new file mode 100644 index 000000000..904103d2b --- /dev/null +++ b/src/main/java/com/algorand/algosdk/transaction/AccessConverter.java @@ -0,0 +1,144 @@ +package com.algorand.algosdk.transaction; + +import com.algorand.algosdk.crypto.Address; + +import java.util.ArrayList; +import java.util.List; + +/** + * AccessConverter handles the conversion from high-level AppResourceRef instances + * to index-based ResourceRef instances that go-algorand expects. + * + * This follows the same pattern as BoxReference.fromAppBoxReference() method. + */ +public class AccessConverter { + + /** + * Convert a list of high-level AppResourceRef to index-based ResourceRef. + * This handles index 0 special cases and ensures proper referencing. + * + * @param appRefs High-level resource references + * @param sender Transaction sender (used for index 0 address references) + * @param currentAppId Current application ID (used for index 0 app references) + * @return List of index-based ResourceRef for serialization + */ + public static List convertToResourceRefs( + List appRefs, + Address sender, + Long currentAppId) { + + if (appRefs == null || appRefs.isEmpty()) { + return new ArrayList<>(); + } + + List result = new ArrayList<>(); + + // First pass: Create basic ResourceRef entries for addresses, assets, and apps + for (AppResourceRef appRef : appRefs) { + if (appRef instanceof AppResourceRef.AddressRef) { + AppResourceRef.AddressRef addrRef = (AppResourceRef.AddressRef) appRef; + result.add(ResourceRef.forAddress(addrRef.getAddress())); + } else if (appRef instanceof AppResourceRef.AssetRef) { + AppResourceRef.AssetRef assetRef = (AppResourceRef.AssetRef) appRef; + result.add(ResourceRef.forAsset(assetRef.getAssetId())); + } else if (appRef instanceof AppResourceRef.AppRef) { + AppResourceRef.AppRef appRefInner = (AppResourceRef.AppRef) appRef; + result.add(ResourceRef.forApp(appRefInner.getAppId())); + } + } + + // Second pass: Handle compound references (holding, locals, box) with proper indices + for (AppResourceRef appRef : appRefs) { + if (appRef instanceof AppResourceRef.HoldingRef) { + AppResourceRef.HoldingRef holdingRef = (AppResourceRef.HoldingRef) appRef; + long addressIndex = findOrAddAddressIndex( + holdingRef.getAddress(), sender, result); + long assetIndex = findOrAddAssetIndex( + holdingRef.getAssetId(), result); + result.add(ResourceRef.forHolding( + new ResourceRef.HoldingRef(addressIndex, assetIndex))); + + } else if (appRef instanceof AppResourceRef.LocalsRef) { + AppResourceRef.LocalsRef localsRef = (AppResourceRef.LocalsRef) appRef; + long addressIndex = findOrAddAddressIndex( + localsRef.getAddress(), sender, result); + long appIndex = findOrAddAppIndex( + localsRef.getAppId(), currentAppId, result); + result.add(ResourceRef.forLocals( + new ResourceRef.LocalsRef(addressIndex, appIndex))); + + } else if (appRef instanceof AppResourceRef.BoxRef) { + AppResourceRef.BoxRef boxRef = (AppResourceRef.BoxRef) appRef; + long appIndex = findOrAddAppIndex( + boxRef.getAppId(), currentAppId, result); + result.add(ResourceRef.forBox( + new ResourceRef.BoxRef(appIndex, boxRef.getName()))); + } + } + + return result; + } + + /** + * Find or add an address to the resource list and return its index. + * Handles index 0 special case (sender). + */ + private static long findOrAddAddressIndex(Address address, Address sender, List resources) { + // Special case: index 0 = sender + if (address == null || address.equals(sender)) { + return 0; + } + + // Look for existing address in the list + for (int i = 0; i < resources.size(); i++) { + ResourceRef ref = resources.get(i); + if (ref.address != null && ref.address.equals(address)) { + return i + 1; // 1-based indexing (0 is special) + } + } + + // Add address if not found + resources.add(ResourceRef.forAddress(address)); + return resources.size(); // 1-based indexing + } + + /** + * Find or add an asset to the resource list and return its index. + */ + private static long findOrAddAssetIndex(long assetId, List resources) { + // Look for existing asset in the list + for (int i = 0; i < resources.size(); i++) { + ResourceRef ref = resources.get(i); + if (ref.asset != null && ref.asset.equals(assetId)) { + return i + 1; // 1-based indexing + } + } + + // Add asset if not found + resources.add(ResourceRef.forAsset(assetId)); + return resources.size(); // 1-based indexing + } + + /** + * Find or add an app to the resource list and return its index. + * Handles index 0 special case (current app). + */ + private static long findOrAddAppIndex(long appId, Long currentAppId, List resources) { + // Special case: index 0 = current app + if (currentAppId != null && appId == currentAppId) { + return 0; + } + + // Look for existing app in the list + for (int i = 0; i < resources.size(); i++) { + ResourceRef ref = resources.get(i); + if (ref.app != null && ref.app.equals(appId)) { + return i + 1; // 1-based indexing (0 is special) + } + } + + // Add app if not found + resources.add(ResourceRef.forApp(appId)); + return resources.size(); // 1-based indexing + } +} \ No newline at end of file diff --git a/src/main/java/com/algorand/algosdk/transaction/AppResourceRef.java b/src/main/java/com/algorand/algosdk/transaction/AppResourceRef.java new file mode 100644 index 000000000..46f474517 --- /dev/null +++ b/src/main/java/com/algorand/algosdk/transaction/AppResourceRef.java @@ -0,0 +1,256 @@ +package com.algorand.algosdk.transaction; + +import com.algorand.algosdk.crypto.Address; + +import java.util.Arrays; +import java.util.Objects; + +/** + * AppResourceRef represents a high-level resource reference that will be converted + * to index-based ResourceRef entries. This provides a user-friendly API. + * + * This follows the same pattern as AppBoxReference -> BoxReference conversion. + */ +public abstract class AppResourceRef { + + /** + * Address reference. + */ + public static class AddressRef extends AppResourceRef { + private final Address address; + + public AddressRef(Address address) { + this.address = address; + } + + public Address getAddress() { + return address; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + AddressRef that = (AddressRef) o; + return Objects.equals(address, that.address); + } + + @Override + public int hashCode() { + return Objects.hash(address); + } + + @Override + public String toString() { + return "AddressRef{address=" + address + '}'; + } + } + + /** + * Asset reference. + */ + public static class AssetRef extends AppResourceRef { + private final long assetId; + + public AssetRef(long assetId) { + this.assetId = assetId; + } + + public long getAssetId() { + return assetId; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + AssetRef assetRef = (AssetRef) o; + return assetId == assetRef.assetId; + } + + @Override + public int hashCode() { + return Objects.hash(assetId); + } + + @Override + public String toString() { + return "AssetRef{assetId=" + assetId + '}'; + } + } + + /** + * Application reference. + */ + public static class AppRef extends AppResourceRef { + private final long appId; + + public AppRef(long appId) { + this.appId = appId; + } + + public long getAppId() { + return appId; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + AppRef appRef = (AppRef) o; + return appId == appRef.appId; + } + + @Override + public int hashCode() { + return Objects.hash(appId); + } + + @Override + public String toString() { + return "AppRef{appId=" + appId + '}'; + } + } + + /** + * Holding reference (account + asset). + */ + public static class HoldingRef extends AppResourceRef { + private final Address address; + private final long assetId; + + public HoldingRef(Address address, long assetId) { + this.address = address; + this.assetId = assetId; + } + + public Address getAddress() { + return address; + } + + public long getAssetId() { + return assetId; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + HoldingRef that = (HoldingRef) o; + return assetId == that.assetId && Objects.equals(address, that.address); + } + + @Override + public int hashCode() { + return Objects.hash(address, assetId); + } + + @Override + public String toString() { + return "HoldingRef{address=" + address + ", assetId=" + assetId + '}'; + } + } + + /** + * Locals reference (account + app). + */ + public static class LocalsRef extends AppResourceRef { + private final Address address; + private final long appId; + + public LocalsRef(Address address, long appId) { + this.address = address; + this.appId = appId; + } + + public Address getAddress() { + return address; + } + + public long getAppId() { + return appId; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + LocalsRef localsRef = (LocalsRef) o; + return appId == localsRef.appId && Objects.equals(address, localsRef.address); + } + + @Override + public int hashCode() { + return Objects.hash(address, appId); + } + + @Override + public String toString() { + return "LocalsRef{address=" + address + ", appId=" + appId + '}'; + } + } + + /** + * Box reference. + */ + public static class BoxRef extends AppResourceRef { + private final long appId; + private final byte[] name; + + public BoxRef(long appId, byte[] name) { + this.appId = appId; + this.name = name == null ? new byte[0] : Arrays.copyOf(name, name.length); + } + + public long getAppId() { + return appId; + } + + public byte[] getName() { + return Arrays.copyOf(name, name.length); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + BoxRef boxRef = (BoxRef) o; + return appId == boxRef.appId && Arrays.equals(name, boxRef.name); + } + + @Override + public int hashCode() { + return Objects.hash(appId, Arrays.hashCode(name)); + } + + @Override + public String toString() { + return "BoxRef{appId=" + appId + ", name=" + Arrays.toString(name) + '}'; + } + } + + // Factory methods + public static AddressRef forAddress(Address address) { + return new AddressRef(address); + } + + public static AssetRef forAsset(long assetId) { + return new AssetRef(assetId); + } + + public static AppRef forApp(long appId) { + return new AppRef(appId); + } + + public static HoldingRef forHolding(Address address, long assetId) { + return new HoldingRef(address, assetId); + } + + public static LocalsRef forLocals(Address address, long appId) { + return new LocalsRef(address, appId); + } + + public static BoxRef forBox(long appId, byte[] name) { + return new BoxRef(appId, name); + } +} \ No newline at end of file diff --git a/src/main/java/com/algorand/algosdk/transaction/ResourceRef.java b/src/main/java/com/algorand/algosdk/transaction/ResourceRef.java new file mode 100644 index 000000000..7a6e422e5 --- /dev/null +++ b/src/main/java/com/algorand/algosdk/transaction/ResourceRef.java @@ -0,0 +1,331 @@ +package com.algorand.algosdk.transaction; + +import com.algorand.algosdk.crypto.Address; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.Arrays; +import java.util.Objects; + +/** + * ResourceRef is a reference to a resource in an application call transaction. + * It can reference different types of resources like accounts, assets, applications, holdings, locals, or boxes. + * Only one resource type should be set per ResourceRef instance. + */ +@JsonInclude(JsonInclude.Include.NON_DEFAULT) +public class ResourceRef { + @JsonProperty("d") + public Address address; + + @JsonProperty("s") + public Long asset; + + @JsonProperty("p") + public Long app; + + @JsonProperty("h") + public HoldingRef holding; + + @JsonProperty("l") + public LocalsRef locals; + + @JsonProperty("b") + public BoxRef box; + + /** + * Default constructor for ResourceRef. + */ + public ResourceRef() {} + + /** + * JsonCreator constructor for ResourceRef deserialization. + */ + @JsonCreator + public ResourceRef( + @JsonProperty("d") byte[] address, + @JsonProperty("s") Long asset, + @JsonProperty("p") Long app, + @JsonProperty("h") HoldingRef holding, + @JsonProperty("l") LocalsRef locals, + @JsonProperty("b") BoxRef box) { + if (address != null) { + this.address = new Address(address); + } + this.asset = asset; + this.app = app; + this.holding = holding; + this.locals = locals; + this.box = box; + } + + /** + * Create a ResourceRef for an account address. + */ + public static ResourceRef forAddress(Address address) { + ResourceRef ref = new ResourceRef(); + ref.address = address; + return ref; + } + + /** + * Create a ResourceRef for an asset. + */ + public static ResourceRef forAsset(long assetId) { + ResourceRef ref = new ResourceRef(); + ref.asset = assetId; + return ref; + } + + /** + * Create a ResourceRef for an application. + */ + public static ResourceRef forApp(long appId) { + ResourceRef ref = new ResourceRef(); + ref.app = appId; + return ref; + } + + /** + * Create a ResourceRef for a holding reference. + */ + public static ResourceRef forHolding(HoldingRef holdingRef) { + ResourceRef ref = new ResourceRef(); + ref.holding = holdingRef; + return ref; + } + + /** + * Create a ResourceRef for a locals reference. + */ + public static ResourceRef forLocals(LocalsRef localsRef) { + ResourceRef ref = new ResourceRef(); + ref.locals = localsRef; + return ref; + } + + /** + * Create a ResourceRef for a box reference. + */ + public static ResourceRef forBox(BoxRef boxRef) { + ResourceRef ref = new ResourceRef(); + ref.box = boxRef; + return ref; + } + + /** + * Check if this ResourceRef is empty (no resource type is set). + */ + @JsonIgnore + public boolean isEmpty() { + return address == null && asset == null && app == null && + holding == null && locals == null && box == null; + } + + /** + * Validate that only one resource type is set. + * @throws IllegalStateException if multiple resource types are set + */ + @JsonIgnore + public void validate() { + int setCount = 0; + if (address != null) setCount++; + if (asset != null) setCount++; + if (app != null) setCount++; + if (holding != null) setCount++; + if (locals != null) setCount++; + if (box != null) setCount++; + + if (setCount > 1) { + throw new IllegalStateException("ResourceRef can only have one resource type set"); + } + if (setCount == 0) { + throw new IllegalStateException("ResourceRef must have one resource type set"); + } + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + ResourceRef that = (ResourceRef) o; + return Objects.equals(address, that.address) && + Objects.equals(asset, that.asset) && + Objects.equals(app, that.app) && + Objects.equals(holding, that.holding) && + Objects.equals(locals, that.locals) && + Objects.equals(box, that.box); + } + + @Override + public int hashCode() { + return Objects.hash(address, asset, app, holding, locals, box); + } + + @Override + public String toString() { + return "ResourceRef{" + + "address=" + address + + ", asset=" + asset + + ", app=" + app + + ", holding=" + holding + + ", locals=" + locals + + ", box=" + box + + '}'; + } + + /** + * HoldingRef represents a reference to an asset holding of an account. + * Both fields are indices into the Access array, matching the Go implementation. + */ + @JsonInclude(JsonInclude.Include.NON_DEFAULT) + public static class HoldingRef { + @JsonProperty("d") + public Long addressIndex; // Index into Access array (0 = sender) + + @JsonProperty("s") + public Long assetIndex; // Index into Access array + + public HoldingRef() {} + + @JsonCreator + public HoldingRef( + @JsonProperty("d") Long addressIndex, + @JsonProperty("s") Long assetIndex) { + this.addressIndex = addressIndex; + this.assetIndex = assetIndex; + } + + public HoldingRef(long addressIndex, long assetIndex) { + this.addressIndex = addressIndex; + this.assetIndex = assetIndex; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + HoldingRef that = (HoldingRef) o; + return Objects.equals(addressIndex, that.addressIndex) && Objects.equals(assetIndex, that.assetIndex); + } + + @Override + public int hashCode() { + return Objects.hash(addressIndex, assetIndex); + } + + @Override + public String toString() { + return "HoldingRef{" + + "addressIndex=" + addressIndex + + ", assetIndex=" + assetIndex + + '}'; + } + } + + /** + * LocalsRef represents a reference to the local state of an account for an application. + * Both fields are indices into the Access array, matching the Go implementation. + */ + @JsonInclude(JsonInclude.Include.NON_DEFAULT) + public static class LocalsRef { + @JsonProperty("d") + public Long addressIndex; // Index into Access array (0 = sender) + + @JsonProperty("p") + public Long appIndex; // Index into Access array (0 = current app) + + public LocalsRef() {} + + @JsonCreator + public LocalsRef( + @JsonProperty("d") Long addressIndex, + @JsonProperty("p") Long appIndex) { + this.addressIndex = addressIndex; + this.appIndex = appIndex; + } + + public LocalsRef(long addressIndex, long appIndex) { + this.addressIndex = addressIndex; + this.appIndex = appIndex; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + LocalsRef that = (LocalsRef) o; + return Objects.equals(addressIndex, that.addressIndex) && Objects.equals(appIndex, that.appIndex); + } + + @Override + public int hashCode() { + return Objects.hash(addressIndex, appIndex); + } + + @Override + public String toString() { + return "LocalsRef{" + + "addressIndex=" + addressIndex + + ", appIndex=" + appIndex + + '}'; + } + } + + /** + * BoxRef represents a reference to a box of an application. + * The index field references the application in the Access array. + */ + @JsonInclude(JsonInclude.Include.NON_DEFAULT) + public static class BoxRef { + @JsonProperty("i") + public Long index; // Index into Access array (0 = current app) + + @JsonProperty("n") + public byte[] name; + + public BoxRef() {} + + @JsonCreator + public BoxRef( + @JsonProperty("i") Long index, + @JsonProperty("n") byte[] name) { + this.index = index; + this.name = name == null ? new byte[0] : Arrays.copyOf(name, name.length); + } + + public BoxRef(long index, byte[] name) { + this.index = index; + this.name = name == null ? new byte[0] : Arrays.copyOf(name, name.length); + } + + @JsonIgnore + public byte[] getName() { + return name == null ? new byte[0] : Arrays.copyOf(name, name.length); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + BoxRef boxRef = (BoxRef) o; + return Objects.equals(index, boxRef.index) && Arrays.equals(name, boxRef.name); + } + + @Override + public int hashCode() { + int result = Objects.hash(index); + result = 31 * result + Arrays.hashCode(name); + return result; + } + + @Override + public String toString() { + return "BoxRef{" + + "index=" + index + + ", name=" + Arrays.toString(name) + + '}'; + } + } +} \ No newline at end of file diff --git a/src/main/java/com/algorand/algosdk/transaction/Transaction.java b/src/main/java/com/algorand/algosdk/transaction/Transaction.java index 51f4058f2..16c12bacf 100644 --- a/src/main/java/com/algorand/algosdk/transaction/Transaction.java +++ b/src/main/java/com/algorand/algosdk/transaction/Transaction.java @@ -165,6 +165,10 @@ public class Transaction implements Serializable { @JsonProperty("apep") public Long extraPages = 0L; + /* access field - unifies accounts, foreignApps, foreignAssets, and boxReferences */ + @JsonProperty("al") + public List access = new ArrayList<>(); + /* state proof fields */ @JsonProperty("sptype") public Integer stateProofType = null; @@ -242,6 +246,8 @@ private Transaction(@JsonProperty("type") Type type, @JsonProperty("apls") StateSchema localStateSchema, @JsonProperty("apsu") byte[] clearStateProgram, @JsonProperty("apep") Long extraPages, + // access fields + @JsonProperty("al") List access, // heartbeat fields @JsonProperty("hb") HeartbeatTxnFields heartbeatFields ) throws IOException { @@ -295,6 +301,7 @@ private Transaction(@JsonProperty("type") Type type, localStateSchema, clearStateProgram == null ? null : new TEALProgram(clearStateProgram), extraPages, + access == null ? new ArrayList<>() : access, heartbeatFields ); } @@ -355,6 +362,7 @@ private Transaction( StateSchema localStateSchema, TEALProgram clearStateProgram, Long extraPages, + List access, HeartbeatTxnFields heartbeatFields ) { if (type != null) this.type = type; @@ -400,6 +408,7 @@ private Transaction( if (localStateSchema != null) this.localStateSchema = localStateSchema; if (clearStateProgram != null) this.clearStateProgram = clearStateProgram; if (extraPages != null) this.extraPages = extraPages; + if (access != null) this.access = access; if (heartbeatFields != null) this.heartbeatFields = heartbeatFields; } diff --git a/src/test/java/com/algorand/algosdk/transaction/TestResourceRef.java b/src/test/java/com/algorand/algosdk/transaction/TestResourceRef.java new file mode 100644 index 000000000..961e5e838 --- /dev/null +++ b/src/test/java/com/algorand/algosdk/transaction/TestResourceRef.java @@ -0,0 +1,228 @@ +package com.algorand.algosdk.transaction; + +import com.algorand.algosdk.crypto.Address; +import org.junit.jupiter.api.Test; + +import java.security.NoSuchAlgorithmException; +import java.util.Arrays; + +import static org.junit.jupiter.api.Assertions.*; + +public class TestResourceRef { + + @Test + public void testResourceRefForAddress() throws NoSuchAlgorithmException { + Address addr = new Address("XBYLS2E6YI6XXL5BWCAMOA4GTWHXWENZMX5UHXMRNWWUQ7BXCY5WC5TEPA"); + ResourceRef ref = ResourceRef.forAddress(addr); + + assertNotNull(ref.address); + assertEquals(addr, ref.address); + assertNull(ref.asset); + assertNull(ref.app); + assertNull(ref.holding); + assertNull(ref.locals); + assertNull(ref.box); + assertFalse(ref.isEmpty()); + assertDoesNotThrow(ref::validate); + } + + @Test + public void testResourceRefForAsset() { + ResourceRef ref = ResourceRef.forAsset(123L); + + assertNull(ref.address); + assertEquals(Long.valueOf(123L), ref.asset); + assertNull(ref.app); + assertNull(ref.holding); + assertNull(ref.locals); + assertNull(ref.box); + assertFalse(ref.isEmpty()); + assertDoesNotThrow(ref::validate); + } + + @Test + public void testResourceRefForApp() { + ResourceRef ref = ResourceRef.forApp(456L); + + assertNull(ref.address); + assertNull(ref.asset); + assertEquals(Long.valueOf(456L), ref.app); + assertNull(ref.holding); + assertNull(ref.locals); + assertNull(ref.box); + assertFalse(ref.isEmpty()); + assertDoesNotThrow(ref::validate); + } + + @Test + public void testResourceRefForHolding() { + // Index-based approach: addressIndex=0 (sender), assetIndex=1 (first asset in access list) + ResourceRef.HoldingRef holding = new ResourceRef.HoldingRef(0L, 1L); + ResourceRef ref = ResourceRef.forHolding(holding); + + assertNull(ref.address); + assertNull(ref.asset); + assertNull(ref.app); + assertNotNull(ref.holding); + assertEquals(Long.valueOf(0L), ref.holding.addressIndex); + assertEquals(Long.valueOf(1L), ref.holding.assetIndex); + assertNull(ref.locals); + assertNull(ref.box); + assertFalse(ref.isEmpty()); + assertDoesNotThrow(ref::validate); + } + + @Test + public void testResourceRefForLocals() { + // Index-based approach: addressIndex=0 (sender), appIndex=0 (current app) + ResourceRef.LocalsRef locals = new ResourceRef.LocalsRef(0L, 0L); + ResourceRef ref = ResourceRef.forLocals(locals); + + assertNull(ref.address); + assertNull(ref.asset); + assertNull(ref.app); + assertNull(ref.holding); + assertNotNull(ref.locals); + assertEquals(Long.valueOf(0L), ref.locals.addressIndex); + assertEquals(Long.valueOf(0L), ref.locals.appIndex); + assertNull(ref.box); + assertFalse(ref.isEmpty()); + assertDoesNotThrow(ref::validate); + } + + @Test + public void testResourceRefForBox() { + byte[] boxName = "test-box".getBytes(); + ResourceRef.BoxRef box = new ResourceRef.BoxRef(0L, boxName); // Use index 0 for current app + ResourceRef ref = ResourceRef.forBox(box); + + assertNull(ref.address); + assertNull(ref.asset); + assertNull(ref.app); + assertNull(ref.holding); + assertNull(ref.locals); + assertNotNull(ref.box); + assertEquals(Long.valueOf(0L), ref.box.index); + assertArrayEquals(boxName, ref.box.name); + assertFalse(ref.isEmpty()); + assertDoesNotThrow(ref::validate); + } + + @Test + public void testEmptyResourceRef() { + ResourceRef ref = new ResourceRef(); + + assertNull(ref.address); + assertNull(ref.asset); + assertNull(ref.app); + assertNull(ref.holding); + assertNull(ref.locals); + assertNull(ref.box); + assertTrue(ref.isEmpty()); + } + + @Test + public void testResourceRefValidationFailsWhenEmpty() { + ResourceRef ref = new ResourceRef(); + + IllegalStateException exception = assertThrows(IllegalStateException.class, ref::validate); + assertEquals("ResourceRef must have one resource type set", exception.getMessage()); + } + + @Test + public void testResourceRefValidationFailsWhenMultipleSet() throws NoSuchAlgorithmException { + ResourceRef ref = new ResourceRef(); + ref.address = new Address("XBYLS2E6YI6XXL5BWCAMOA4GTWHXWENZMX5UHXMRNWWUQ7BXCY5WC5TEPA"); + ref.asset = 123L; + + IllegalStateException exception = assertThrows(IllegalStateException.class, ref::validate); + assertEquals("ResourceRef can only have one resource type set", exception.getMessage()); + } + + @Test + public void testHoldingRefConstructorAndMethods() { + // Index-based approach: addressIndex=2, assetIndex=3 (arbitrary indices) + ResourceRef.HoldingRef holding = new ResourceRef.HoldingRef(2L, 3L); + + assertEquals(Long.valueOf(2L), holding.addressIndex); + assertEquals(Long.valueOf(3L), holding.assetIndex); + + // Test equality + ResourceRef.HoldingRef holding2 = new ResourceRef.HoldingRef(2L, 3L); + assertEquals(holding, holding2); + assertEquals(holding.hashCode(), holding2.hashCode()); + + // Test toString + assertTrue(holding.toString().contains("HoldingRef")); + } + + @Test + public void testLocalsRefConstructorAndMethods() { + // Index-based approach: addressIndex=1, appIndex=2 (arbitrary indices) + ResourceRef.LocalsRef locals = new ResourceRef.LocalsRef(1L, 2L); + + assertEquals(Long.valueOf(1L), locals.addressIndex); + assertEquals(Long.valueOf(2L), locals.appIndex); + + // Test equality + ResourceRef.LocalsRef locals2 = new ResourceRef.LocalsRef(1L, 2L); + assertEquals(locals, locals2); + assertEquals(locals.hashCode(), locals2.hashCode()); + + // Test toString + assertTrue(locals.toString().contains("LocalsRef")); + } + + @Test + public void testBoxRefConstructorAndMethods() { + byte[] boxName = "my-box".getBytes(); + ResourceRef.BoxRef box = new ResourceRef.BoxRef(101L, boxName); + + assertEquals(Long.valueOf(101L), box.index); + assertArrayEquals(boxName, box.name); + assertArrayEquals(boxName, box.getName()); // Test getter makes defensive copy + + // Test equality + ResourceRef.BoxRef box2 = new ResourceRef.BoxRef(101L, "my-box".getBytes()); + assertEquals(box, box2); + assertEquals(box.hashCode(), box2.hashCode()); + + // Test toString + assertTrue(box.toString().contains("BoxRef")); + } + + @Test + public void testBoxRefWithNullName() { + ResourceRef.BoxRef box = new ResourceRef.BoxRef(102L, null); + + assertEquals(Long.valueOf(102L), box.index); + assertArrayEquals(new byte[0], box.name); + assertArrayEquals(new byte[0], box.getName()); + } + + @Test + public void testResourceRefEquality() throws NoSuchAlgorithmException { + Address addr = new Address("XBYLS2E6YI6XXL5BWCAMOA4GTWHXWENZMX5UHXMRNWWUQ7BXCY5WC5TEPA"); + + ResourceRef ref1 = ResourceRef.forAddress(addr); + ResourceRef ref2 = ResourceRef.forAddress(addr); + ResourceRef ref3 = ResourceRef.forAsset(123L); + + assertEquals(ref1, ref2); + assertEquals(ref1.hashCode(), ref2.hashCode()); + assertNotEquals(ref1, ref3); + assertNotEquals(ref1.hashCode(), ref3.hashCode()); + + // Test toString + assertTrue(ref1.toString().contains("ResourceRef")); + } + + @Test + public void testResourceRefEqualityWithNull() throws NoSuchAlgorithmException { + ResourceRef ref = ResourceRef.forAsset(123L); + + assertNotEquals(ref, null); + assertNotEquals(ref, "not a ResourceRef"); + assertEquals(ref, ref); // self equality + } +} \ No newline at end of file diff --git a/src/test/java/com/algorand/algosdk/transaction/TestUseAccess.java b/src/test/java/com/algorand/algosdk/transaction/TestUseAccess.java new file mode 100644 index 000000000..4a66129f7 --- /dev/null +++ b/src/test/java/com/algorand/algosdk/transaction/TestUseAccess.java @@ -0,0 +1,224 @@ +package com.algorand.algosdk.transaction; + +import com.algorand.algosdk.builder.transaction.ApplicationCallTransactionBuilder; +import com.algorand.algosdk.crypto.Address; +import com.algorand.algosdk.util.Encoder; +import org.junit.jupiter.api.Test; + +import java.math.BigInteger; +import java.security.NoSuchAlgorithmException; +import java.util.Arrays; +import java.util.Collections; + +import static org.junit.jupiter.api.Assertions.*; + +public class TestUseAccess { + + private static final String SENDER_ADDR = "XBYLS2E6YI6XXL5BWCAMOA4GTWHXWENZMX5UHXMRNWWUQ7BXCY5WC5TEPA"; + private static final String ACCOUNT_ADDR = "47YPQTIGQEO7T4Y4RWDYWEKV6RTR2UNBQXBABEEGM72ESWDQNCQ52OPASU"; + + @Test + public void testUseAccessDefaultIsFalse() throws NoSuchAlgorithmException { + Address sender = new Address(SENDER_ADDR); + Address account = new Address(ACCOUNT_ADDR); + + // Default behavior should be useAccess=false (legacy fields) + Transaction txn = ApplicationCallTransactionBuilder.Builder() + .sender(sender) + .applicationId(1001L) + // No .useAccess() call - should default to false + .accounts(Collections.singletonList(account)) + .foreignApps(Collections.singletonList(456L)) + .firstValid(BigInteger.valueOf(1000)) + .lastValid(BigInteger.valueOf(2000)) + .genesisHash(Encoder.decodeFromBase64("SGO1GKSzyE7IEPItTxCByw9x8FmnrCDexi9/cOUJOiI=")) + .build(); + + // Should use legacy fields + assertEquals(1, txn.accounts.size()); + assertEquals(account, txn.accounts.get(0)); + assertEquals(1, txn.foreignApps.size()); + assertEquals(Long.valueOf(456L), txn.foreignApps.get(0)); + assertTrue(txn.access.isEmpty()); + } + + @Test + public void testUseAccessFalseUsesLegacyFields() throws NoSuchAlgorithmException { + Address sender = new Address(SENDER_ADDR); + Address account = new Address(ACCOUNT_ADDR); + + // Explicit useAccess=false should use legacy fields + Transaction txn = ApplicationCallTransactionBuilder.Builder() + .sender(sender) + .applicationId(1001L) + .useAccess(false) + .accounts(Collections.singletonList(account)) + .foreignApps(Collections.singletonList(456L)) + .foreignAssets(Collections.singletonList(123L)) + .firstValid(BigInteger.valueOf(1000)) + .lastValid(BigInteger.valueOf(2000)) + .genesisHash(Encoder.decodeFromBase64("SGO1GKSzyE7IEPItTxCByw9x8FmnrCDexi9/cOUJOiI=")) + .build(); + + // Verify legacy fields are populated + assertEquals(1, txn.accounts.size()); + assertEquals(account, txn.accounts.get(0)); + assertEquals(1, txn.foreignApps.size()); + assertEquals(Long.valueOf(456L), txn.foreignApps.get(0)); + assertEquals(1, txn.foreignAssets.size()); + assertEquals(Long.valueOf(123L), txn.foreignAssets.get(0)); + + // Verify access field is empty + assertTrue(txn.access.isEmpty()); + } + + @Test + public void testUseAccessTrueTranslatesFields() throws NoSuchAlgorithmException { + Address sender = new Address(SENDER_ADDR); + Address account = new Address(ACCOUNT_ADDR); + + // useAccess=true should translate same method calls into access field + Transaction txn = ApplicationCallTransactionBuilder.Builder() + .sender(sender) + .applicationId(1001L) + .useAccess(true) // Translation mode + .accounts(Collections.singletonList(account)) + .foreignApps(Collections.singletonList(456L)) + .foreignAssets(Collections.singletonList(123L)) + .firstValid(BigInteger.valueOf(1000)) + .lastValid(BigInteger.valueOf(2000)) + .genesisHash(Encoder.decodeFromBase64("SGO1GKSzyE7IEPItTxCByw9x8FmnrCDexi9/cOUJOiI=")) + .build(); + + // Verify legacy fields are empty (translated) + assertTrue(txn.accounts.isEmpty()); + assertTrue(txn.foreignApps.isEmpty()); + assertTrue(txn.foreignAssets.isEmpty()); + + // Verify access field has translated references + assertNotNull(txn.access); + assertEquals(3, txn.access.size()); + + // Check each reference type exists in access field + boolean foundAccount = false, foundApp = false, foundAsset = false; + for (ResourceRef ref : txn.access) { + if (ref.address != null && ref.address.equals(account)) foundAccount = true; + if (ref.app != null && ref.app.equals(456L)) foundApp = true; + if (ref.asset != null && ref.asset.equals(123L)) foundAsset = true; + } + assertTrue(foundAccount, "Account reference should be in access field"); + assertTrue(foundApp, "App reference should be in access field"); + assertTrue(foundAsset, "Asset reference should be in access field"); + } + + @Test + public void testBoxReferencesWorkWithBothModes() throws NoSuchAlgorithmException { + Address sender = new Address(SENDER_ADDR); + + AppBoxReference boxRef = new AppBoxReference(1001L, "test-box".getBytes()); + + // Test with useAccess=false + Transaction legacyTxn = ApplicationCallTransactionBuilder.Builder() + .sender(sender) + .applicationId(1001L) + .useAccess(false) + .boxReferences(Collections.singletonList(boxRef)) + .firstValid(BigInteger.valueOf(1000)) + .lastValid(BigInteger.valueOf(2000)) + .genesisHash(Encoder.decodeFromBase64("SGO1GKSzyE7IEPItTxCByw9x8FmnrCDexi9/cOUJOiI=")) + .build(); + + assertEquals(1, legacyTxn.boxReferences.size()); + assertTrue(legacyTxn.access.isEmpty()); + + // Test with useAccess=true + Transaction accessTxn = ApplicationCallTransactionBuilder.Builder() + .sender(sender) + .applicationId(1001L) + .useAccess(true) + .boxReferences(Arrays.asList(boxRef)) + .firstValid(BigInteger.valueOf(1000)) + .lastValid(BigInteger.valueOf(2000)) + .genesisHash(Encoder.decodeFromBase64("SGO1GKSzyE7IEPItTxCByw9x8FmnrCDexi9/cOUJOiI=")) + .build(); + + assertTrue(accessTxn.boxReferences.isEmpty()); + assertEquals(1, accessTxn.access.size()); + assertNotNull(accessTxn.access.get(0).box); + } + + @Test + public void testMixedReferencesTranslateCorrectly() throws NoSuchAlgorithmException { + Address sender = new Address(SENDER_ADDR); + Address account1 = new Address(ACCOUNT_ADDR); + Address account2 = new Address(SENDER_ADDR); + + // Test that multiple references of different types all get translated + Transaction txn = ApplicationCallTransactionBuilder.Builder() + .sender(sender) + .applicationId(1001L) + .useAccess(true) + .accounts(Arrays.asList(account1, account2)) // Multiple accounts + .foreignApps(Arrays.asList(456L, 789L)) // Multiple apps + .foreignAssets(Arrays.asList(123L, 999L)) // Multiple assets + .boxReferences(Arrays.asList(new AppBoxReference(1001L, "box1".getBytes()))) + .firstValid(BigInteger.valueOf(1000)) + .lastValid(BigInteger.valueOf(2000)) + .genesisHash(Encoder.decodeFromBase64("SGO1GKSzyE7IEPItTxCByw9x8FmnrCDexi9/cOUJOiI=")) + .build(); + + // All legacy fields should be empty + assertTrue(txn.accounts.isEmpty()); + assertTrue(txn.foreignApps.isEmpty()); + assertTrue(txn.foreignAssets.isEmpty()); + assertTrue(txn.boxReferences.isEmpty()); + + // Access field should contain all references + assertNotNull(txn.access); + assertEquals(7, txn.access.size()); // 2 accounts + 2 apps + 2 assets + 1 box = 7 + + // Verify we have references of each type + int addressCount = 0, appCount = 0, assetCount = 0, boxCount = 0; + for (ResourceRef ref : txn.access) { + if (ref.address != null) addressCount++; + if (ref.app != null) appCount++; + if (ref.asset != null) assetCount++; + if (ref.box != null) boxCount++; + } + + assertEquals(2, addressCount, "Should have 2 address references"); + assertEquals(2, appCount, "Should have 2 app references"); + assertEquals(2, assetCount, "Should have 2 asset references"); + assertEquals(1, boxCount, "Should have 1 box reference"); + } + + @Test + public void testEmptyReferencesWork() throws NoSuchAlgorithmException { + Address sender = new Address(SENDER_ADDR); + + // Test both modes with no references + Transaction legacyTxn = ApplicationCallTransactionBuilder.Builder() + .sender(sender) + .applicationId(1001L) + .useAccess(false) + .firstValid(BigInteger.valueOf(1000)) + .lastValid(BigInteger.valueOf(2000)) + .genesisHash(Encoder.decodeFromBase64("SGO1GKSzyE7IEPItTxCByw9x8FmnrCDexi9/cOUJOiI=")) + .build(); + + assertTrue(legacyTxn.accounts.isEmpty()); + assertTrue(legacyTxn.access.isEmpty()); + + Transaction accessTxn = ApplicationCallTransactionBuilder.Builder() + .sender(sender) + .applicationId(1001L) + .useAccess(true) + .firstValid(BigInteger.valueOf(1000)) + .lastValid(BigInteger.valueOf(2000)) + .genesisHash(Encoder.decodeFromBase64("SGO1GKSzyE7IEPItTxCByw9x8FmnrCDexi9/cOUJOiI=")) + .build(); + + assertTrue(accessTxn.accounts.isEmpty()); + assertTrue(accessTxn.access.isEmpty()); + } +} \ No newline at end of file