Java tutorial
/** * Copyright 2016 Myrle Krantz * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package org.fineract.module.stellar.horizonadapter; import com.google.common.cache.CacheBuilder; import com.google.common.cache.CacheLoader; import com.google.common.cache.LoadingCache; import javafx.util.Pair; import org.fineract.module.stellar.service.UnexpectedException; import org.fineract.module.stellar.federation.StellarAccountId; import org.slf4j.Logger; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import org.stellar.sdk.*; import org.stellar.sdk.responses.*; import javax.annotation.PostConstruct; import java.io.IOException; import java.math.BigDecimal; import java.net.URISyntaxException; import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.function.Supplier; @Component public class HorizonServerUtilities { private static int STELLAR_MINIMUM_BALANCE = 20; private static int VAULT_ACCOUNT_INITIAL_BALANCE = STELLAR_MINIMUM_BALANCE + 1; //a transaction (for example to issue currency) costs 100 stroops. 10^-5 stellar. private final Logger logger; @Value("${stellar.horizon-address}") private String serverAddress; private Server server; @Value("${stellar.installation-account-private-key}") private String installationAccountPrivateKey; @Value("${stellar.new-account-initial-balance}") private int initialBalance = STELLAR_MINIMUM_BALANCE; @Value("${stellar.local-federation-domain}") private String localFederationDomain; private final LoadingCache<String, Account> accounts; private final LoadingCache<Pair<String, StellarAccountId>, Map<VaultOffer, Long>> offers; @Autowired HorizonServerUtilities(@Qualifier("stellarBridgeLogger") final Logger logger) { this.logger = logger; int STELLAR_TRUSTLINE_BALANCE_REQUIREMENT = 10; int initialBalance = Math.max(this.initialBalance, STELLAR_MINIMUM_BALANCE + STELLAR_TRUSTLINE_BALANCE_REQUIREMENT); if (initialBalance != this.initialBalance) { logger.info("Initial balance cannot be lower than 30. Configured value is being ignored: %i", this.initialBalance); } this.initialBalance = initialBalance; accounts = CacheBuilder.newBuilder().build(new CacheLoader<String, Account>() { public Account load(final String accountId) throws InvalidConfigurationException { final KeyPair accountKeyPair = KeyPair.fromAccountId(accountId); final StellarAccountHelpers accountHelper = getAccount(accountKeyPair); final Long sequenceNumber = accountHelper.get().getSequenceNumber(); return new Account(accountKeyPair, sequenceNumber); } }); offers = CacheBuilder.newBuilder() .build(new CacheLoader<Pair<String, StellarAccountId>, Map<VaultOffer, Long>>() { @Override public Map<VaultOffer, Long> load(final Pair<String, StellarAccountId> accountIdVaultId) { return VaultOffer.getVaultOffers(server, accountIdVaultId.getKey(), accountIdVaultId.getValue()); } }); } @PostConstruct void init() { server = new Server(serverAddress); } /** * Create an account on the stellar server to be used by a Mifos tenant. This account will * need a minimum initial balance of 30 lumens, to be derived from the installation account. * A higher minimum can be configured, and should be if more than one trustline is needed. * A tenant-associated vault account uses up one trustline. * * @return The KeyPair of the account which was created. * * @throws InvalidConfigurationException if the horizon server named in the configuration cannot * be reached. Either the address is wrong or the horizon server named is't running, or there is * a problem with the network. * @throws StellarAccountCreationFailedException if the horizon server refused the account * creation request. */ public KeyPair createAccount() throws InvalidConfigurationException, StellarAccountCreationFailedException { logger.info("HorizonServerUtilities.createAccount"); final KeyPair installationAccountKeyPair = KeyPair.fromSecretSeed(installationAccountPrivateKey); final Account installationAccount = accounts.getUnchecked(installationAccountKeyPair.getAccountId()); final KeyPair newTenantStellarAccountKeyPair = KeyPair.random(); createAccountForKeyPair(initialBalance, newTenantStellarAccountKeyPair, installationAccountKeyPair, installationAccount); setOptionsForNewAccount(newTenantStellarAccountKeyPair, installationAccountKeyPair); return newTenantStellarAccountKeyPair; } public void removeAccount(final StellarAccountId stellarAccountId, final char[] stellarAccountPrivateKey) throws InvalidConfigurationException, AccountMergerFailedException { final KeyPair installationAccountKeyPair = KeyPair.fromSecretSeed(installationAccountPrivateKey); StellarAccountHelpers account = getAccount(KeyPair.fromAccountId(stellarAccountId.getPublicKey())); if (account.getAllNonnativeBalancesStream().count() != 0) throw AccountMergerFailedException.accountIsNotEmpty(stellarAccountId.getPublicKey()); mergeAccount(installationAccountKeyPair, KeyPair.fromSecretSeed(stellarAccountPrivateKey)); } public KeyPair createVaultAccount() throws InvalidConfigurationException, StellarAccountCreationFailedException { logger.info("HorizonServerUtilities.createVaultAccount"); final KeyPair installationAccountKeyPair = KeyPair.fromSecretSeed(installationAccountPrivateKey); final Account installationAccount = accounts.getUnchecked(installationAccountKeyPair.getAccountId()); final KeyPair newTenantStellarVaultAccountKeyPair = KeyPair.random(); createAccountForKeyPair(VAULT_ACCOUNT_INITIAL_BALANCE, newTenantStellarVaultAccountKeyPair, installationAccountKeyPair, installationAccount); setOptionsForNewVaultAccount(newTenantStellarVaultAccountKeyPair); return newTenantStellarVaultAccountKeyPair; } public void removeVaultAccount(final StellarAccountId stellarAccountId, final char[] stellarAccountPrivateKey, final StellarAccountId stellarVaultAccountId, final char[] stellarVaultAccountPrivateKey) throws InvalidConfigurationException, StellarPaymentFailedException, StellarTrustlineAdjustmentFailedException, AccountMergerFailedException { final KeyPair accountKeyPair = KeyPair.fromAccountId(stellarAccountId.getPublicKey()); StellarAccountHelpers account = getAccount(accountKeyPair); account.getVaultBalancesStream(stellarVaultAccountId.getPublicKey()) .forEach(balance -> adjustVaultIssuedAssets(stellarAccountId, stellarAccountPrivateKey, stellarVaultAccountId, stellarVaultAccountPrivateKey, balance.getAssetCode(), BigDecimal.ZERO)); account = getAccount(accountKeyPair); //Get the new balances. if (0 != account.getVaultBalancesStream(stellarVaultAccountId.getPublicKey()).count()) throw AccountMergerFailedException.vaultIssuedAssetsAreStillInCirculation(); mergeAccount(accountKeyPair, KeyPair.fromSecretSeed(stellarVaultAccountPrivateKey)); } private void mergeAccount(final KeyPair lastManStanding, final KeyPair dyingBreed) throws InvalidConfigurationException, AccountMergerFailedException { final Account accountSequencer = accounts.getUnchecked(dyingBreed.getAccountId()); final AccountMergeOperation.Builder mergeOperation = new AccountMergeOperation.Builder(lastManStanding) .setSourceAccount(dyingBreed); final Transaction.Builder transactionBuilder = new Transaction.Builder(accountSequencer); transactionBuilder.addOperation(mergeOperation.build()); submitTransaction(accountSequencer, transactionBuilder, dyingBreed, AccountMergerFailedException::stellarRefused); } public BigDecimal adjustVaultIssuedAssets(final StellarAccountId stellarAccountId, final char[] stellarAccountPrivateKey, final StellarAccountId stellarVaultAccountId, final char[] stellarVaultAccountPrivateKey, final String assetCode, final BigDecimal amount) throws InvalidConfigurationException, StellarPaymentFailedException, StellarTrustlineAdjustmentFailedException { final BigDecimal currentVaultIssuedAssets = currencyTrustSize(stellarAccountId, assetCode, stellarVaultAccountId); final BigDecimal adjustmentRequired = amount.subtract(currentVaultIssuedAssets); if (adjustmentRequired.compareTo(BigDecimal.ZERO) < 0) { final BigDecimal currentVaultIssuedAssetsHeldByTenant = getBalanceByIssuer(stellarAccountId, assetCode, stellarVaultAccountId); final BigDecimal adjustmentPossible = currentVaultIssuedAssetsHeldByTenant .min(adjustmentRequired.abs()); final BigDecimal finalBalance = currentVaultIssuedAssets.subtract(adjustmentPossible); simplePay(stellarVaultAccountId, adjustmentPossible, assetCode, stellarVaultAccountId, stellarAccountPrivateKey); setTrustLineSize(stellarAccountPrivateKey, stellarVaultAccountId, assetCode, finalBalance); return finalBalance; } else if (adjustmentRequired.compareTo(BigDecimal.ZERO) > 0) { setTrustLineSize(stellarAccountPrivateKey, stellarVaultAccountId, assetCode, amount); simplePay(stellarAccountId, adjustmentRequired, assetCode, stellarVaultAccountId, stellarVaultAccountPrivateKey); return amount; } else { return currentVaultIssuedAssets; } } /** * Creates a line of trust between stellar accounts for one currency, and up to a maximum amount. * * @param stellarAccountPrivateKey the key of the account doing the trusting * @param issuingStellarAccountId the account Id of the account to be trusted. * @param assetCode the currency symbol of the currency to be trusted. See * https://www.stellar.org/developers/learn/concepts/assets.html * for a description of how to create a valid asset code. * @param maximumAmount the maximum amount of the currency to be trusted. * * @throws InvalidConfigurationException if the horizon server named in the configuration cannot * be reached. Either the address is wrong or the horizon server named is't running, or there is * a problem with the network. * @throws StellarTrustlineAdjustmentFailedException if the creation of the trustline failed for any * other reason. */ public BigDecimal setTrustLineSize(final char[] stellarAccountPrivateKey, final StellarAccountId issuingStellarAccountId, final String assetCode, final BigDecimal maximumAmount) throws InvalidConfigurationException, StellarTrustlineAdjustmentFailedException { logger.info("HorizonServerUtilities.setTrustLineSize"); final KeyPair trustingAccountKeyPair = KeyPair.fromSecretSeed(stellarAccountPrivateKey); final Account trustingAccount = accounts.getUnchecked(trustingAccountKeyPair.getAccountId()); final Asset asset = StellarAccountHelpers.getAsset(assetCode, issuingStellarAccountId); final BigDecimal balance = getAccount(trustingAccountKeyPair).getBalanceOfAsset(asset); //Can't make it smaller than the balance final BigDecimal trustSize = balance.max(maximumAmount); final Transaction.Builder trustTransactionBuilder = new Transaction.Builder(trustingAccount); final ChangeTrustOperation trustOperation = new ChangeTrustOperation.Builder(asset, StellarAccountHelpers.bigDecimalToStellarBalance(trustSize)).build(); trustTransactionBuilder.addOperation(trustOperation); submitTransaction(trustingAccount, trustTransactionBuilder, trustingAccountKeyPair, StellarTrustlineAdjustmentFailedException::trustLineTransactionFailed); return trustSize; } public void simplePay(final StellarAccountId targetAccountId, final BigDecimal amount, final String assetCode, final StellarAccountId issuingAccountId, final char[] stellarAccountPrivateKey) throws InvalidConfigurationException, StellarPaymentFailedException { logger.info("HorizonServerUtilities.simplePay"); final Asset asset = StellarAccountHelpers.getAsset(assetCode, issuingAccountId); pay(targetAccountId, amount, asset, asset, stellarAccountPrivateKey); } private void pay(final StellarAccountId targetAccountId, final BigDecimal amount, final Asset sendAsset, final Asset receiveAsset, final char[] stellarAccountPrivateKey) throws InvalidConfigurationException, StellarPaymentFailedException { final KeyPair sourceAccountKeyPair = KeyPair.fromSecretSeed(stellarAccountPrivateKey); final KeyPair targetAccountKeyPair = KeyPair.fromAccountId(targetAccountId.getPublicKey()); final Account sourceAccount = accounts.getUnchecked(sourceAccountKeyPair.getAccountId()); final Transaction.Builder transferTransactionBuilder = new Transaction.Builder(sourceAccount); final PathPaymentOperation paymentOperation = new PathPaymentOperation.Builder(sendAsset, StellarAccountHelpers.bigDecimalToStellarBalance(amount), targetAccountKeyPair, receiveAsset, StellarAccountHelpers.bigDecimalToStellarBalance(amount)).setSourceAccount(sourceAccountKeyPair) .build(); transferTransactionBuilder.addOperation(paymentOperation); if (targetAccountId.getSubAccount().isPresent()) { final Memo subAccountMemo = Memo.text(targetAccountId.getSubAccount().get()); transferTransactionBuilder.addMemo(subAccountMemo); } submitTransaction(sourceAccount, transferTransactionBuilder, sourceAccountKeyPair, StellarPaymentFailedException::transactionFailed); } public BigDecimal getBalance(final StellarAccountId stellarAccountId, final String assetCode) { logger.info("HorizonServerUtilities.getBalance"); return getAccount(KeyPair.fromAccountId(stellarAccountId.getPublicKey())).getBalance(assetCode); } public BigDecimal getInstallationAccountBalance(final String assetCode, final StellarAccountId issuingStellarAccountId) throws InvalidConfigurationException { logger.info("HorizonServerUtilities.getInstallationAccountBalance"); final StellarAccountId installationAccountId = StellarAccountId .mainAccount(KeyPair.fromSecretSeed(installationAccountPrivateKey).getAccountId()); return this.getBalanceByIssuer(installationAccountId, assetCode, issuingStellarAccountId); } public BigDecimal getBalanceByIssuer(final StellarAccountId stellarAccountId, final String assetCode, final StellarAccountId accountIdOfIssuingStellarAddress) throws InvalidConfigurationException { logger.info("HorizonServerUtilities.getBalanceByIssuer"); final Asset asset = StellarAccountHelpers.getAsset(assetCode, accountIdOfIssuingStellarAddress); return getAccount(KeyPair.fromAccountId(stellarAccountId.getPublicKey())).getBalanceOfAsset(asset); } public BigDecimal currencyTrustSize(final StellarAccountId trustingAccountId, final String assetCode, final StellarAccountId issuingAccountId) { logger.info("HorizonServerUtilities.currencyTrustSize"); final StellarAccountHelpers trustingAccount = getAccount( KeyPair.fromAccountId(trustingAccountId.getPublicKey())); final Asset asset = StellarAccountHelpers.getAsset(assetCode, issuingAccountId); return trustingAccount.getTrustInAsset(asset); } public void adjustOffer(final char[] stellarAccountPrivateKey, final StellarAccountId vaultAccountId, final String assetCode) throws InvalidConfigurationException, StellarOfferAdjustmentFailedException { logger.info("HorizonServerUtilities.adjustOffer"); final KeyPair accountKeyPair = KeyPair.fromSecretSeed(stellarAccountPrivateKey); final Account account = accounts.getUnchecked(accountKeyPair.getAccountId()); final Asset vaultAsset = StellarAccountHelpers.getAsset(assetCode, vaultAccountId); final StellarAccountHelpers accountHelper = getAccount(accountKeyPair); final BigDecimal balanceOfVaultAsset = accountHelper.getBalanceOfAsset(vaultAsset); final BigDecimal remainingTrustInVaultAsset = accountHelper.getRemainingTrustInAsset(vaultAsset); final Pair<String, StellarAccountId> offerKey = new Pair<>(accountKeyPair.getAccountId(), vaultAccountId); offers.refresh(offerKey); final Map<VaultOffer, Long> vaultOffers = offers.getUnchecked(offerKey); final Transaction.Builder transactionBuilder = new Transaction.Builder(account); accountHelper.getAllNonnativeBalancesStream(assetCode, vaultAsset) .filter(balance -> !balance.getAssetIssuer().equals(vaultAccountId.getPublicKey())) .map(balance -> offerOperation(accountKeyPair, StellarAccountHelpers.getAssetOfBalance(balance), vaultAsset, determineOfferAmount(balanceOfVaultAsset, remainingTrustInVaultAsset, StellarAccountHelpers.stellarBalanceToBigDecimal(balance.getBalance())), determineOfferId(vaultOffers, balance))) .forEach(transactionBuilder::addOperation); if (transactionBuilder.getOperationsCount() != 0) { submitTransaction(account, transactionBuilder, accountKeyPair, StellarOfferAdjustmentFailedException::new); } } static BigDecimal determineOfferAmount(final BigDecimal balanceOfVaultAsset, final BigDecimal remainingTrustInVaultAsset, final BigDecimal balanceOfMatchingAsset) { return remainingTrustInVaultAsset.min(balanceOfVaultAsset.min(balanceOfMatchingAsset)); } Optional<Long> determineOfferId(final Map<VaultOffer, Long> vaultOffers, final AccountResponse.Balance balance) { return Optional .ofNullable(vaultOffers.get(new VaultOffer(balance.getAssetCode(), balance.getAssetIssuer()))); } private ManageOfferOperation offerOperation(final KeyPair sourceAccountKeyPair, final Asset fromAsset, final Asset toAsset, final BigDecimal amount, final Optional<Long> offerId) { final ManageOfferOperation.Builder offerOperationBuilder = new ManageOfferOperation.Builder(fromAsset, toAsset, StellarAccountHelpers.bigDecimalToStellarBalance(amount), "1"); offerOperationBuilder.setSourceAccount(sourceAccountKeyPair); offerId.ifPresent(offerOperationBuilder::setOfferId); return offerOperationBuilder.build(); } private void createAccountForKeyPair(final int initialBalance, final KeyPair newAccountKeyPair, final KeyPair installationAccountKeyPair, final Account installationAccount) throws InvalidConfigurationException, StellarAccountCreationFailedException { final Transaction.Builder transactionBuilder = new Transaction.Builder(installationAccount); final CreateAccountOperation createAccountOperation = new CreateAccountOperation.Builder(newAccountKeyPair, Integer.toString(initialBalance)).setSourceAccount(installationAccountKeyPair).build(); transactionBuilder.addOperation(createAccountOperation); submitTransaction(installationAccount, transactionBuilder, installationAccountKeyPair, StellarAccountCreationFailedException::new); } private void setOptionsForNewAccount(final KeyPair newAccountKeyPair, final KeyPair installationAccountKeyPair) throws StellarAccountCreationFailedException, InvalidConfigurationException { final Account newAccount = accounts.getUnchecked(newAccountKeyPair.getAccountId()); final Transaction.Builder transactionBuilder = new Transaction.Builder(newAccount); final SetOptionsOperation.Builder setOptionsOperationBuilder = new SetOptionsOperation.Builder() .setSourceAccount(newAccountKeyPair); if (localFederationDomain != null) { setOptionsOperationBuilder.setHomeDomain(localFederationDomain); } setOptionsOperationBuilder.setInflationDestination(installationAccountKeyPair); transactionBuilder.addOperation(setOptionsOperationBuilder.build()); submitTransaction(newAccount, transactionBuilder, newAccountKeyPair, StellarAccountCreationFailedException::new); } private void setOptionsForNewVaultAccount(final KeyPair newAccountKeyPair) throws StellarAccountCreationFailedException, InvalidConfigurationException { final Account newAccount = accounts.getUnchecked(newAccountKeyPair.getAccountId()); final Transaction.Builder transactionBuilder = new Transaction.Builder(newAccount); final SetOptionsOperation.Builder setOptionsOperationBuilder = new SetOptionsOperation.Builder() .setSourceAccount(newAccountKeyPair); setOptionsOperationBuilder.setSetFlags(0x2); transactionBuilder.addOperation(setOptionsOperationBuilder.build()); submitTransaction(newAccount, transactionBuilder, newAccountKeyPair, StellarAccountCreationFailedException::new); } private StellarAccountHelpers getAccount(final KeyPair installationAccountKeyPair) throws InvalidConfigurationException { final AccountResponse installationAccount; try { installationAccount = server.accounts().account(installationAccountKeyPair); } catch (final IOException e) { throw InvalidConfigurationException.unreachableStellarServerAddress(serverAddress); } if (installationAccount == null) { throw InvalidConfigurationException.invalidInstallationAccountSecretSeed(); } return new StellarAccountHelpers(installationAccount); } public void findPathPay(final StellarAccountId targetAccountId, final BigDecimal amount, final String assetCode, final char[] stellarAccountPrivateKey) throws InvalidConfigurationException, StellarPaymentFailedException { logger.info("HorizonServerUtilities.findPathPay"); final KeyPair sourceAccountKeyPair = KeyPair.fromSecretSeed(stellarAccountPrivateKey); final KeyPair targetAccountKeyPair = KeyPair.fromAccountId(targetAccountId.getPublicKey()); final StellarAccountHelpers sourceAccount = getAccount(sourceAccountKeyPair); final StellarAccountHelpers targetAccount = getAccount(targetAccountKeyPair); final Set<Asset> targetAssets = targetAccount.findAssetsWithTrust(amount, assetCode); final Set<Asset> sourceAssets = sourceAccount.findAssetsWithBalance(amount, assetCode); final Optional<Pair<Asset, Asset>> assetPair = findAnyMatchingAssetPair(amount, sourceAssets, targetAssets, sourceAccountKeyPair, targetAccountKeyPair); if (!assetPair.isPresent()) throw StellarPaymentFailedException.noPathExists(assetCode); pay(targetAccountId, amount, assetPair.get().getKey(), assetPair.get().getValue(), stellarAccountPrivateKey); } private Optional<Pair<Asset, Asset>> findAnyMatchingAssetPair(final BigDecimal amount, final Set<Asset> sourceAssets, final Set<Asset> targetAssets, final KeyPair sourceAccountKeyPair, final KeyPair targetAccountKeyPair) { if (sourceAssets.isEmpty()) return Optional.empty(); for (final Asset targetAsset : targetAssets) { Page<PathResponse> paths; try { paths = server.paths().sourceAccount(sourceAccountKeyPair).destinationAccount(targetAccountKeyPair) .destinationAsset(targetAsset) .destinationAmount(StellarAccountHelpers.bigDecimalToStellarBalance(amount)).execute(); } catch (final IOException e) { return Optional.empty(); } while (paths != null && paths.getRecords() != null) { for (final PathResponse path : paths.getRecords()) { if (StellarAccountHelpers.stellarBalanceToBigDecimal(path.getSourceAmount()) .compareTo(amount) <= 0) { if (sourceAssets.contains(path.getSourceAsset())) { return Optional.of(new Pair<>(path.getSourceAsset(), targetAsset)); } } } try { paths = ((paths.getLinks() == null) || (paths.getLinks().getNext() == null)) ? null : paths.getNextPage(); } catch (final URISyntaxException e) { throw new UnexpectedException(); } catch (final IOException e) { return Optional.empty(); } } } return Optional.empty(); } private <T extends Exception> void submitTransaction(final Account transactionSubmitter, final Transaction.Builder transactionBuilder, final KeyPair signingKeyPair, final Supplier<T> failureHandler) throws T { try { //final Long sequenceNumberSubmitted = account.getSequenceNumber(); //noinspection SynchronizationOnLocalVariableOrMethodParameter synchronized (transactionSubmitter) { final Transaction transaction = transactionBuilder.build(); transaction.sign(signingKeyPair); final SubmitTransactionResponse transactionResponse = server.submitTransaction(transaction); if (!transactionResponse.isSuccess()) { if (transactionResponse.getExtras() != null) { logger.info("Stellar transaction failed, request: {}", transactionResponse.getExtras().getEnvelopeXdr()); logger.info("Stellar transaction failed, response: {}", transactionResponse.getExtras().getResultXdr()); } else { logger.info("Stellar transaction failed. No extra information available."); } //TODO: resend transaction if you get a bad sequence. /*Thread.sleep(6000); //Wait for ledger to close. Long sequenceNumberShouldHaveBeen = server.accounts().account(account.getKeypair()).getSequenceNumber(); if (sequenceNumberSubmitted != sequenceNumberShouldHaveBeen) { logger.info("Sequence number submitted: {}, Sequence number should have been: {}", sequenceNumberSubmitted, sequenceNumberShouldHaveBeen); }*/ throw failureHandler.get(); } } } catch (final IOException e) { throw InvalidConfigurationException.unreachableStellarServerAddress(serverAddress); } } }