diff --git a/src/main/java/org/stellar/sdk/SorobanServer.java b/src/main/java/org/stellar/sdk/SorobanServer.java index 05cef0c90..b6efe0bc3 100644 --- a/src/main/java/org/stellar/sdk/SorobanServer.java +++ b/src/main/java/org/stellar/sdk/SorobanServer.java @@ -6,8 +6,10 @@ import java.io.IOException; import java.net.SocketTimeoutException; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collection; import java.util.Collections; +import java.util.LinkedHashMap; import java.util.List; import java.util.Optional; import java.util.UUID; @@ -43,12 +45,14 @@ import org.stellar.sdk.responses.sorobanrpc.GetLedgerEntriesResponse; import org.stellar.sdk.responses.sorobanrpc.GetLedgersResponse; import org.stellar.sdk.responses.sorobanrpc.GetNetworkResponse; +import org.stellar.sdk.responses.sorobanrpc.GetSACBalanceResponse; import org.stellar.sdk.responses.sorobanrpc.GetTransactionResponse; import org.stellar.sdk.responses.sorobanrpc.GetTransactionsResponse; import org.stellar.sdk.responses.sorobanrpc.GetVersionInfoResponse; import org.stellar.sdk.responses.sorobanrpc.SendTransactionResponse; import org.stellar.sdk.responses.sorobanrpc.SimulateTransactionResponse; import org.stellar.sdk.responses.sorobanrpc.SorobanRpcResponse; +import org.stellar.sdk.scval.Scv; import org.stellar.sdk.xdr.ContractDataDurability; import org.stellar.sdk.xdr.LedgerEntry; import org.stellar.sdk.xdr.LedgerEntryType; @@ -560,6 +564,65 @@ public SendTransactionResponse sendTransaction(Transaction transaction) { "sendTransaction", params, new TypeToken>() {}); } + /** + * Fetches the balance of a specific asset for a contract. This is useful for checking the balance + * of a contract in a specific asset. + * + * @param contractId The contract ID containing the asset balance. Encoded as Stellar Contract + * Address. e.g. CAB... + * @param asset The asset to check the balance for. This should be a valid asset object. + * @param network The network to use for the asset. + * @return A {@link GetSACBalanceResponse} which will contain the balance entry details if and + * only if the request returned a valid balance ledger entry. If it doesn't, the {@code + * balanceEntry} field will not exist. + * @see Stellar Asset Contract (SAC) documentation + */ + public GetSACBalanceResponse getSACBalance(String contractId, Asset asset, Network network) { + if (!StrKey.isValidContract(contractId)) { + throw new IllegalArgumentException("Invalid contract ID: " + contractId); + } + + LedgerKey ledgerKey = + LedgerKey.builder() + .discriminant(LedgerEntryType.CONTRACT_DATA) + .contractData( + LedgerKey.LedgerKeyContractData.builder() + .contract(Scv.toAddress(asset.getContractId(network)).getAddress()) + .key( + Scv.toVec( + Arrays.asList(Scv.toSymbol("Balance"), Scv.toAddress(contractId)))) + .durability(ContractDataDurability.PERSISTENT) + .build()) + .build(); + + GetLedgerEntriesResponse response = this.getLedgerEntries(Collections.singleton(ledgerKey)); + + List entries = response.getEntries(); + if (entries == null || entries.isEmpty()) { + return GetSACBalanceResponse.builder().latestLedger(response.getLatestLedger()).build(); + } + + GetLedgerEntriesResponse.LedgerEntryResult entry = entries.get(0); + LedgerEntry.LedgerEntryData ledgerEntryData = + Util.parseXdr(entry.getXdr(), LedgerEntry.LedgerEntryData::fromXdrBase64); + + LinkedHashMap balanceMap = + Scv.fromMap(ledgerEntryData.getContractData().getVal()); + + return GetSACBalanceResponse.builder() + .latestLedger(response.getLatestLedger()) + .balanceEntry( + GetSACBalanceResponse.BalanceEntry.builder() + .liveUntilLedgerSeq(entry.getLiveUntilLedger()) + .lastModifiedLedgerSeq(entry.getLastModifiedLedger()) + .amount(Scv.fromInt128(balanceMap.get(Scv.toSymbol("amount"))).toString()) + .authorized(Scv.fromBoolean(balanceMap.get(Scv.toSymbol("authorized")))) + .clawback(Scv.fromBoolean(balanceMap.get(Scv.toSymbol("clawback")))) + .build()) + .build(); + } + public static Transaction assembleTransaction( Transaction transaction, SimulateTransactionResponse simulateTransactionResponse) { if (!transaction.isSorobanTransaction()) { diff --git a/src/main/java/org/stellar/sdk/responses/sorobanrpc/GetSACBalanceResponse.java b/src/main/java/org/stellar/sdk/responses/sorobanrpc/GetSACBalanceResponse.java new file mode 100644 index 000000000..82f2b2acf --- /dev/null +++ b/src/main/java/org/stellar/sdk/responses/sorobanrpc/GetSACBalanceResponse.java @@ -0,0 +1,29 @@ +package org.stellar.sdk.responses.sorobanrpc; + +import lombok.Builder; +import lombok.Value; +import org.jetbrains.annotations.Nullable; +import org.stellar.sdk.Asset; +import org.stellar.sdk.Network; + +/** Response for {@link org.stellar.sdk.SorobanServer#getSACBalance(String, Asset, Network)}. */ +@Builder +@Value +public class GetSACBalanceResponse { + Long latestLedger; + + /** + * The balance entry for the account. If there is not a valid balance entry, this will be null. + */ + @Nullable BalanceEntry balanceEntry; + + @Builder + @Value + public static class BalanceEntry { + Long liveUntilLedgerSeq; + Long lastModifiedLedgerSeq; + String amount; + Boolean authorized; + Boolean clawback; + } +} diff --git a/src/test/java/org/stellar/sdk/SorobanServerTest.java b/src/test/java/org/stellar/sdk/SorobanServerTest.java index 60d8cbfd7..5e5a45b33 100644 --- a/src/test/java/org/stellar/sdk/SorobanServerTest.java +++ b/src/test/java/org/stellar/sdk/SorobanServerTest.java @@ -3,6 +3,7 @@ import static java.util.Collections.singletonList; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; @@ -46,6 +47,7 @@ import org.stellar.sdk.responses.sorobanrpc.GetLedgerEntriesResponse; import org.stellar.sdk.responses.sorobanrpc.GetLedgersResponse; import org.stellar.sdk.responses.sorobanrpc.GetNetworkResponse; +import org.stellar.sdk.responses.sorobanrpc.GetSACBalanceResponse; import org.stellar.sdk.responses.sorobanrpc.GetTransactionResponse; import org.stellar.sdk.responses.sorobanrpc.GetTransactionsResponse; import org.stellar.sdk.responses.sorobanrpc.GetVersionInfoResponse; @@ -1354,6 +1356,54 @@ public MockResponse dispatch(@NotNull RecordedRequest recordedRequest) mockWebServer.close(); } + @Test + public void testGetSACBalance() throws IOException { + String filePath = "src/test/resources/soroban_server/get_sac_balance.json"; + String json = new String(Files.readAllBytes(Paths.get(filePath))); + MockWebServer mockWebServer = new MockWebServer(); + Dispatcher dispatcher = + new Dispatcher() { + @NotNull + @Override + public MockResponse dispatch(@NotNull RecordedRequest recordedRequest) { + SorobanRpcRequest sorobanRpcRequest = + gson.fromJson( + recordedRequest.getBody().readUtf8(), + new TypeToken>() {}.getType()); + if ("POST".equals(recordedRequest.getMethod()) + && sorobanRpcRequest.getMethod().equals("getLedgerEntries") + && "AAAABgAAAAHXkotywnA8z+r365/0701QSlWouXn8m0UOoshCtNHOYQAAABAAAAABAAAAAgAAAA8AAAAHQmFsYW5jZQAAAAASAAAAATS6wvBZn2PXUHdI2oDWZqeECiy17cpcL6qrr1lqpqu6AAAAAQ==" + .equals( + sorobanRpcRequest.getParams().getKeys().stream() + .findFirst() + .orElse(null))) { + return new MockResponse().setResponseCode(200).setBody(json); + } + return new MockResponse().setResponseCode(404); + } + }; + mockWebServer.setDispatcher(dispatcher); + mockWebServer.start(); + + HttpUrl baseUrl = mockWebServer.url(""); + SorobanServer server = new SorobanServer(baseUrl.toString()); + GetSACBalanceResponse balance = + server.getSACBalance( + "CA2LVQXQLGPWHV2QO5ENVAGWM2TYICRMWXW4UXBPVKV26WLKU2V3UTH5", + Asset.createNativeAsset(), + Network.TESTNET); + assertEquals((Long) 1104160L, balance.getLatestLedger()); + assertNotNull(balance.getBalanceEntry()); + assertEquals((Long) 3163876L, balance.getBalanceEntry().getLiveUntilLedgerSeq()); + assertEquals((Long) 1090887L, balance.getBalanceEntry().getLastModifiedLedgerSeq()); + assertEquals("28", balance.getBalanceEntry().getAmount()); + assertTrue(balance.getBalanceEntry().getAuthorized()); + assertFalse(balance.getBalanceEntry().getClawback()); + + server.close(); + mockWebServer.close(); + } + @Test public void testSorobanRpcErrorResponseThrows() throws IOException { String filePath = "src/test/resources/soroban_server/soroban_rpc_error.json"; diff --git a/src/test/resources/soroban_server/get_sac_balance.json b/src/test/resources/soroban_server/get_sac_balance.json new file mode 100644 index 000000000..03ff4bb40 --- /dev/null +++ b/src/test/resources/soroban_server/get_sac_balance.json @@ -0,0 +1,15 @@ +{ + "jsonrpc": "2.0", + "id": "5715b491-e70c-483b-b000-315735c4129b", + "result": { + "entries": [ + { + "key": "AAAABgAAAAHXkotywnA8z+r365/0701QSlWouXn8m0UOoshCtNHOYQAAABAAAAABAAAAAgAAAA8AAAAHQmFsYW5jZQAAAAASAAAAATS6wvBZn2PXUHdI2oDWZqeECiy17cpcL6qrr1lqpqu6AAAAAQ==", + "xdr": "AAAABgAAAAAAAAAB15KLcsJwPM/q9+uf9O9NUEpVqLl5/JtFDqLIQrTRzmEAAAAQAAAAAQAAAAIAAAAPAAAAB0JhbGFuY2UAAAAAEgAAAAE0usLwWZ9j11B3SNqA1manhAoste3KXC+qq69ZaqarugAAAAEAAAARAAAAAQAAAAMAAAAPAAAABmFtb3VudAAAAAAACgAAAAAAAAAAAAAAAAAAABwAAAAPAAAACmF1dGhvcml6ZWQAAAAAAAAAAAABAAAADwAAAAhjbGF3YmFjawAAAAAAAAAA", + "lastModifiedLedgerSeq": 1090887, + "liveUntilLedgerSeq": 3163876 + } + ], + "latestLedger": 1104160 + } +} \ No newline at end of file