diff --git a/blockchain-demo/src/main/java/com/tangem/demo/datastorage/DummyBlockchainDataStorage.kt b/blockchain-demo/src/main/java/com/tangem/demo/datastorage/DummyBlockchainDataStorage.kt index d5ec124e4..b00f50f7c 100644 --- a/blockchain-demo/src/main/java/com/tangem/demo/datastorage/DummyBlockchainDataStorage.kt +++ b/blockchain-demo/src/main/java/com/tangem/demo/datastorage/DummyBlockchainDataStorage.kt @@ -5,4 +5,5 @@ import com.tangem.blockchain.common.datastorage.BlockchainDataStorage internal object DummyBlockchainDataStorage : BlockchainDataStorage { override suspend fun getOrNull(key: String): String? = null override suspend fun store(key: String, value: String) = Unit + override suspend fun remove(key: String) = Unit } diff --git a/blockchain/src/main/java/com/tangem/blockchain/blockchains/hedera/HederaWalletManager.kt b/blockchain/src/main/java/com/tangem/blockchain/blockchains/hedera/HederaWalletManager.kt index c35cbdbbc..4556d879f 100644 --- a/blockchain/src/main/java/com/tangem/blockchain/blockchains/hedera/HederaWalletManager.kt +++ b/blockchain/src/main/java/com/tangem/blockchain/blockchains/hedera/HederaWalletManager.kt @@ -87,7 +87,7 @@ internal class HederaWalletManager( if (error is BlockchainSdkError) error("Error isn't BlockchainSdkError") } - override suspend fun hasRequirements(currencyType: CryptoCurrencyType): Boolean { + private suspend fun assetRequiresAssociation(currencyType: CryptoCurrencyType): Boolean { return when (currencyType) { is CryptoCurrencyType.Coin -> false is CryptoCurrencyType.Token -> { @@ -99,7 +99,7 @@ internal class HederaWalletManager( } override suspend fun requirementsCondition(currencyType: CryptoCurrencyType): AssetRequirementsCondition? { - if (!hasRequirements(currencyType)) return null + if (!assetRequiresAssociation(currencyType)) return null return when (currencyType) { is CryptoCurrencyType.Coin -> null @@ -110,7 +110,10 @@ internal class HederaWalletManager( } else { val feeValue = exchangeRate * HBAR_TOKEN_ASSOCIATE_USD_COST val feeAmount = Amount(blockchain = wallet.blockchain, value = feeValue) - AssetRequirementsCondition.PaidTransactionWithFee(feeAmount) + AssetRequirementsCondition.PaidTransactionWithFee( + blockchain = blockchain, + feeAmount = feeAmount, + ) } } } @@ -120,7 +123,7 @@ internal class HederaWalletManager( currencyType: CryptoCurrencyType, signer: TransactionSigner, ): SimpleResult { - if (!hasRequirements(currencyType)) return SimpleResult.Success + if (!assetRequiresAssociation(currencyType)) return SimpleResult.Success return when (currencyType) { is CryptoCurrencyType.Coin -> SimpleResult.Success @@ -155,6 +158,10 @@ internal class HederaWalletManager( } } + override suspend fun discardRequirements(currencyType: CryptoCurrencyType): SimpleResult { + return SimpleResult.Success + } + override suspend fun send( transactionData: TransactionData, signer: TransactionSigner, diff --git a/blockchain/src/main/java/com/tangem/blockchain/blockchains/kaspa/KaspaTransaction.java b/blockchain/src/main/java/com/tangem/blockchain/blockchains/kaspa/KaspaTransaction.java index 335140894..e5d81ada0 100644 --- a/blockchain/src/main/java/com/tangem/blockchain/blockchains/kaspa/KaspaTransaction.java +++ b/blockchain/src/main/java/com/tangem/blockchain/blockchains/kaspa/KaspaTransaction.java @@ -24,6 +24,7 @@ // based on BitcoinCashTransaction public class KaspaTransaction extends Transaction { private final byte[] TRANSACTION_SIGNING_DOMAIN = "TransactionSigningHash".getBytes(StandardCharsets.UTF_8); + private final byte[] TRANSACTION_ID = "TransactionID".getBytes(StandardCharsets.UTF_8); private final byte[] TRANSACTION_SIGNING_ECDSA_DOMAIN_HASH = Sha256Hash.of("TransactionSigningHashECDSA".getBytes(StandardCharsets.UTF_8)).getBytes(); private final int BLAKE2B_DIGEST_LENGTH = 32; @@ -130,6 +131,41 @@ public synchronized byte[] hashForSignatureWitness( return Sha256Hash.of(finalBos.toByteArray()).getBytes(); } + public synchronized byte[] transactionHash() { + ByteArrayOutputStream bos = new ByteArrayOutputStream(256); + try { + List inputs = getInputs(); + List outputs = getOutputs(); + + uint16ToByteStreamLE(0, bos); + + uint64ToByteStreamLE(BigInteger.valueOf(inputs.size()), bos); + for (TransactionInput input: inputs) { + bos.write(input.getOutpoint().getHash().getBytes()); + uint32ToByteStreamLE(input.getOutpoint().getIndex(), bos); + uint64ToByteStreamLE(BigInteger.valueOf(0), bos); + uint64ToByteStreamLE(BigInteger.valueOf(0), bos); + } + uint64ToByteStreamLE(BigInteger.valueOf(outputs.size()), bos); + for (TransactionOutput output : outputs) { + byte[] scriptBytes = output.getScriptBytes(); + uint64ToByteStreamLE(BigInteger.valueOf(output.getValue().value), bos); + uint16ToByteStreamLE(0, bos); // version + uint64ToByteStreamLE(BigInteger.valueOf(scriptBytes.length), bos); + bos.write(scriptBytes); + } + uint64ToByteStreamLE(BigInteger.valueOf(getLockTime()), bos); // lock time + bos.write(new byte[20]); // subnetwork id + uint64ToByteStreamLE(BigInteger.valueOf(0), bos); // gas + uint64ToByteStreamLE(BigInteger.valueOf(0), bos); // payload size + } catch (IOException e) { + throw new RuntimeException(e); + } + + Blake2b.Mac digest = Blake2b.Mac.newInstance(TRANSACTION_ID, BLAKE2B_DIGEST_LENGTH); + return digest.digest(bos.toByteArray()); + } + private byte[] blake2bDigestOf(byte[] input) { Blake2b.Mac digest = Blake2b.Mac.newInstance(TRANSACTION_SIGNING_DOMAIN, BLAKE2B_DIGEST_LENGTH); return digest.digest(input); diff --git a/blockchain/src/main/java/com/tangem/blockchain/blockchains/kaspa/KaspaTransactionBuilder.kt b/blockchain/src/main/java/com/tangem/blockchain/blockchains/kaspa/KaspaTransactionBuilder.kt index 92ad69da1..46ac25838 100644 --- a/blockchain/src/main/java/com/tangem/blockchain/blockchains/kaspa/KaspaTransactionBuilder.kt +++ b/blockchain/src/main/java/com/tangem/blockchain/blockchains/kaspa/KaspaTransactionBuilder.kt @@ -1,26 +1,39 @@ package com.tangem.blockchain.blockchains.kaspa +import com.squareup.moshi.adapter import com.tangem.blockchain.blockchains.kaspa.kaspacashaddr.KaspaAddressType import com.tangem.blockchain.blockchains.kaspa.kaspacashaddr.KaspaCashAddr +import com.tangem.blockchain.blockchains.kaspa.krc20.model.* import com.tangem.blockchain.blockchains.kaspa.network.* -import com.tangem.blockchain.common.BlockchainSdkError -import com.tangem.blockchain.common.TransactionData +import com.tangem.blockchain.common.* +import com.tangem.blockchain.common.transaction.Fee import com.tangem.blockchain.extensions.Result +import com.tangem.blockchain.network.moshi +import com.tangem.common.extensions.hexToBytes import com.tangem.common.extensions.isZero import com.tangem.common.extensions.toHexString import org.bitcoinj.core.* import org.bitcoinj.core.Transaction.SigHash +import org.bitcoinj.script.Script import org.bitcoinj.script.ScriptBuilder import org.bitcoinj.script.ScriptOpCodes.* +import org.bouncycastle.jcajce.provider.digest.Blake2b import java.math.BigDecimal import java.math.BigInteger -class KaspaTransactionBuilder { - private lateinit var transaction: KaspaTransaction +class KaspaTransactionBuilder( + private val publicKey: Wallet.PublicKey, +) { private var networkParameters = KaspaMainNetParams() var unspentOutputs: List? = null - fun buildToSign(transactionData: TransactionData): Result> { + private val addressService = KaspaAddressService() + + @OptIn(ExperimentalStdlibApi::class) + private val envelopeAdapter by lazy { moshi.adapter() } + + @Suppress("MagicNumber") + fun buildToSign(transactionData: TransactionData): Result { transactionData.requireUncompiled() if (unspentOutputs.isNullOrEmpty()) { @@ -41,48 +54,235 @@ class KaspaTransactionBuilder { return Result.Failure(BlockchainSdkError.Kaspa.UtxoAmountError(MAX_INPUT_COUNT, maxAmount)) } - transaction = transactionData.toKaspaTransaction(networkParameters, unspentsToSpend, change) + val addressService = KaspaAddressService() + val sourceScript = ScriptBuilder().data(addressService.getPublicKey(transactionData.sourceAddress)).op( + OP_CODESEPARATOR, + ).build() - val hashesForSign: MutableList = MutableList(transaction.inputs.size) { byteArrayOf() } - for (input in transaction.inputs) { - val index = input.index - hashesForSign[index] = transaction.hashForSignatureWitness( - index, - input.scriptBytes, - input.value, - SigHash.ALL, - false, - ) + val destinationAddressDecoded = KaspaCashAddr.decodeCashAddress(transactionData.destinationAddress) + val destinationScript = when (destinationAddressDecoded.addressType) { + KaspaAddressType.P2PK_SCHNORR -> + ScriptBuilder.createP2PKOutputScript(destinationAddressDecoded.hash) + KaspaAddressType.P2PK_ECDSA -> + ScriptBuilder().data(destinationAddressDecoded.hash).op(OP_CODESEPARATOR).build() + KaspaAddressType.P2SH -> { + // known P2SH addresses won't throw + if (destinationAddressDecoded.hash.size != 32) error("Invalid hash length in P2SH address") + ScriptBuilder().op(OP_HASH256).data(destinationAddressDecoded.hash).op(OP_EQUAL).build() + } + null -> error("Null script type") // should never happen } - return Result.Success(hashesForSign) + + val transaction = createKaspaTransaction( + networkParameters = networkParameters, + unspentOutputs = unspentsToSpend, + transformer = { kaspaTransaction -> + kaspaTransaction.addOutput( + Coin.parseCoin(transactionData.amount.value.toPlainString()), + destinationScript, + ) + if (!change.isZero()) { + kaspaTransaction.addOutput( + Coin.parseCoin(change.toPlainString()), + sourceScript, + ) + } + kaspaTransaction + }, + ) + + return Result.Success(transaction) } - fun buildToSend(signatures: ByteArray): KaspaTransactionBody { + fun buildToSend(signatures: ByteArray, transaction: KaspaTransaction): KaspaTransactionBody { for (index in transaction.inputs.indices) { val signature = extractSignature(index, signatures) transaction.inputs[index].scriptSig = ScriptBuilder().data(signature).build() } - return KaspaTransactionBody( - KaspaTransactionData( - inputs = transaction.inputs.map { - KaspaInput( - previousOutpoint = KaspaPreviousOutpoint( - transactionId = it.outpoint.hash.toString(), - index = it.outpoint.index, - ), - signatureScript = it.scriptBytes.toHexString(), - ) - }, - outputs = transaction.outputs.map { - KaspaOutput( - amount = it.value.getValue(), - scriptPublicKey = KaspaScriptPublicKey(it.scriptBytes.toHexString()), + return buildForSendInternal(transaction) + } + + internal fun buildKRC20RevealToSend( + signatures: ByteArray, + redeemScript: RedeemScript, + transaction: KaspaTransaction, + ): KaspaTransactionBody { + for (index in transaction.inputs.indices) { + val signature = extractSignature(index, signatures) + if (index == 0) { + transaction.inputs[index].scriptSig = ScriptBuilder() + .data(signature) + .data(redeemScript.script().program) + .build() + } else { + transaction.inputs[index].scriptSig = ScriptBuilder().data(signature).build() + } + } + return buildForSendInternal(transaction) + } + + @Suppress("LongMethod", "MagicNumber") + internal fun buildKRC20CommitTransactionToSign( + transactionData: TransactionData, + dust: BigDecimal?, + includeFee: Boolean = true, + ): Result { + transactionData.requireUncompiled() + + require(transactionData.amount.type is AmountType.Token) + + if (unspentOutputs.isNullOrEmpty()) { + return Result.Failure( + BlockchainSdkError.CustomError("Unspent outputs are missing"), + ) + } + + val unspentsToSpend = getUnspentsToSpend() + + val transactionFeeAmountValue = transactionData.fee?.amount?.value ?: BigDecimal.ZERO + + val change = calculateChange( + amount = requireNotNull(transactionData.amount.value) { "Transaction amount is null" }, + fee = transactionFeeAmountValue, + unspentOutputs = unspentsToSpend, + ) + + if (change < BigDecimal.ZERO) { // unspentsToSpend not enough to cover transaction amount + val maxAmount = transactionData.amount.value + change + return Result.Failure(BlockchainSdkError.Kaspa.UtxoAmountError(MAX_INPUT_COUNT, maxAmount)) + } + + // We determine the commission value for reveal transaction, + val revealFeeParams = (transactionData.fee as? Fee.Kaspa)?.revealTransactionFee?.takeIf { includeFee } + // if we don't know the commission, commission for reveal transaction will be set to zero + val feeEstimationRevealTransactionValue = revealFeeParams?.value ?: BigDecimal.ZERO + val targetOutputAmountValue = feeEstimationRevealTransactionValue + (dust ?: BigDecimal.ZERO) + + val resultChange = calculateChange( + amount = targetOutputAmountValue, + fee = transactionFeeAmountValue, + unspentOutputs = getUnspentsToSpend(), + ) + + // The envelope will be used to create the RedeemScript and saved for use when building the Reveal transaction + val envelope = Envelope( + p = "krc-20", + op = "transfer", + amt = transactionData.amount.longValueOrZero.toString(), + to = transactionData.destinationAddress, + tick = transactionData.amount.type.token.contractAddress, + ) + + val redeemScript = RedeemScript( + publicKey = publicKey.blockchainKey, + envelope = envelope, + ) + + val transaction = createKaspaTransaction( + networkParameters = networkParameters, + unspentOutputs = unspentsToSpend, + transformer = { kaspaTransaction -> + kaspaTransaction.addOutput( + Coin.parseCoin(targetOutputAmountValue.toPlainString()), + redeemScript.scriptHash(), + ) + if (!resultChange.isZero()) { + val addressService = KaspaAddressService() + val sourceScript = ScriptBuilder() + .data(addressService.getPublicKey(transactionData.sourceAddress)) + .op(OP_CODESEPARATOR) + .build() + kaspaTransaction.addOutput( + Coin.parseCoin(resultChange.toPlainString()), + sourceScript, ) - }, + } + kaspaTransaction + }, + ) + val commitTransaction = CommitTransaction( + transaction = transaction, + hashes = getHashesForSign(transaction), + redeemScript = redeemScript, + sourceAddress = transactionData.sourceAddress, + params = IncompleteTokenTransactionParams( + transactionId = transaction.transactionHash().toHexString(), + amountValue = transactionData.amount.value, + feeAmountValue = targetOutputAmountValue, + envelope = envelope, ), ) + + return Result.Success(commitTransaction) } + internal fun buildKRC20RevealTransactionToSign( + sourceAddress: String, + redeemScript: RedeemScript, + params: IncompleteTokenTransactionParams, + feeAmountValue: BigDecimal, + ): Result { + val utxo = listOf( + KaspaUnspentOutput( + amount = params.feeAmountValue, + outputIndex = 0, + transactionHash = params.transactionId.hexToBytes(), + outputScript = redeemScript.scriptHash().program, + ), + ) + + val change = calculateChange( + amount = BigDecimal.ZERO, + fee = feeAmountValue, + unspentOutputs = utxo, + ) + + val transaction = createKaspaTransaction( + networkParameters = networkParameters, + unspentOutputs = utxo, + transformer = { kaspaTransaction -> + val sourceScript = ScriptBuilder() + .data(addressService.getPublicKey(sourceAddress)) + .op(OP_CODESEPARATOR) + .build() + kaspaTransaction.addOutput( + Coin.parseCoin(change.toPlainString()), + sourceScript, + ) + kaspaTransaction + }, + ) + + val revealTransaction = RevealTransaction( + transaction = transaction, + hashes = getHashesForSign(transaction), + redeemScript = redeemScript, + ) + + return Result.Success(revealTransaction) + } + + private fun buildForSendInternal(transaction: KaspaTransaction) = KaspaTransactionBody( + KaspaTransactionData( + inputs = transaction.inputs.map { + it.scriptSig.program + KaspaInput( + previousOutpoint = KaspaPreviousOutpoint( + transactionId = it.outpoint.hash.toString(), + index = it.outpoint.index, + ), + signatureScript = it.scriptBytes.toHexString(), + ) + }, + outputs = transaction.outputs.map { + KaspaOutput( + amount = it.value.getValue(), + scriptPublicKey = KaspaScriptPublicKey(it.scriptBytes.toHexString()), + ) + }, + ), + ) + @Suppress("MagicNumber") private fun extractSignature(index: Int, signatures: ByteArray): ByteArray { val r = BigInteger(1, signatures.copyOfRange(index * 64, 32 + index * 64)) @@ -106,19 +306,55 @@ class KaspaTransactionBuilder { fun getUnspentsToSpend() = unspentOutputs!!.sortedByDescending { it.amount }.take(getUnspentsToSpendCount()) + fun getHashesForSign(transaction: KaspaTransaction): List { + val hashesForSign: MutableList = MutableList(transaction.inputs.size) { byteArrayOf() } + for (input in transaction.inputs) { + val index = input.index + hashesForSign[index] = transaction.hashForSignatureWitness( + index, + input.scriptBytes, + input.value, + SigHash.ALL, + false, + ) + } + return hashesForSign + } + + private fun RedeemScript.script(): Script { + val kasplexId = "kasplex".toByteArray() + val payload = envelopeAdapter.toJson(envelope).toByteArray() + + return ScriptBuilder() + .data(publicKey) + .op(OP_CODESEPARATOR) + .opFalse() + .op(OP_IF) + .data(kasplexId) + .opTrue() + .opFalse() + .opFalse() + .data(payload) + .op(OP_ENDIF) + .build() + } + + private fun RedeemScript.scriptHash(): Script = ScriptBuilder() + .op(OP_HASH256) + .data(Blake2b.Blake2b256().digest(script().program)) + .op(OP_EQUAL) + .build() + companion object { const val MAX_INPUT_COUNT = 84 // Kaspa rejects transactions with more inputs } } -@Suppress("MagicNumber") -internal fun TransactionData.toKaspaTransaction( +internal fun createKaspaTransaction( networkParameters: NetworkParameters?, unspentOutputs: List, - change: BigDecimal, + transformer: (KaspaTransaction) -> KaspaTransaction, ): KaspaTransaction { - requireUncompiled() - val transaction = KaspaTransaction( networkParameters, ) @@ -138,33 +374,5 @@ internal fun TransactionData.toKaspaTransaction( input.sequenceNumber = 0 } - val addressService = KaspaAddressService() - val sourceScript = ScriptBuilder().data(addressService.getPublicKey(this.sourceAddress)).op(OP_CODESEPARATOR) - .build() - - val destinationAddressDecoded = KaspaCashAddr.decodeCashAddress(this.destinationAddress) - val destinationScript = when (destinationAddressDecoded.addressType) { - KaspaAddressType.P2PK_SCHNORR -> - ScriptBuilder.createP2PKOutputScript(destinationAddressDecoded.hash) - KaspaAddressType.P2PK_ECDSA -> - ScriptBuilder().data(destinationAddressDecoded.hash).op(OP_CODESEPARATOR).build() - KaspaAddressType.P2SH -> { - // known P2SH addresses won't throw - if (destinationAddressDecoded.hash.size != 32) error("Invalid hash length in P2SH address") - ScriptBuilder().op(OP_HASH256).data(destinationAddressDecoded.hash).op(OP_EQUAL).build() - } - null -> error("Null script type") // should never happen - } - - transaction.addOutput( - Coin.parseCoin(this.amount.value!!.toPlainString()), - destinationScript, - ) - if (!change.isZero()) { - transaction.addOutput( - Coin.parseCoin(change.toPlainString()), - sourceScript, - ) - } - return transaction + return transformer(transaction) } diff --git a/blockchain/src/main/java/com/tangem/blockchain/blockchains/kaspa/KaspaWalletManager.kt b/blockchain/src/main/java/com/tangem/blockchain/blockchains/kaspa/KaspaWalletManager.kt index 9afdf4ad2..385612ee2 100644 --- a/blockchain/src/main/java/com/tangem/blockchain/blockchains/kaspa/KaspaWalletManager.kt +++ b/blockchain/src/main/java/com/tangem/blockchain/blockchains/kaspa/KaspaWalletManager.kt @@ -1,23 +1,41 @@ package com.tangem.blockchain.blockchains.kaspa import android.util.Log +import com.tangem.blockchain.blockchains.kaspa.krc20.KaspaKRC20InfoResponse +import com.tangem.blockchain.blockchains.kaspa.krc20.KaspaKRC20NetworkProvider +import com.tangem.blockchain.blockchains.kaspa.krc20.KaspaKRC20TransactionExtras +import com.tangem.blockchain.blockchains.kaspa.krc20.model.IncompleteTokenTransactionParams +import com.tangem.blockchain.blockchains.kaspa.krc20.model.RedeemScript import com.tangem.blockchain.blockchains.kaspa.network.KaspaFeeBucketResponse import com.tangem.blockchain.blockchains.kaspa.network.KaspaInfoResponse import com.tangem.blockchain.blockchains.kaspa.network.KaspaNetworkProvider import com.tangem.blockchain.common.* +import com.tangem.blockchain.common.datastorage.BlockchainSavedData +import com.tangem.blockchain.common.datastorage.implementations.AdvancedDataStorage import com.tangem.blockchain.common.transaction.Fee import com.tangem.blockchain.common.transaction.TransactionFee import com.tangem.blockchain.common.transaction.TransactionSendResult +import com.tangem.blockchain.common.trustlines.AssetRequirementsCondition +import com.tangem.blockchain.common.trustlines.AssetRequirementsManager import com.tangem.blockchain.extensions.Result +import com.tangem.blockchain.extensions.SimpleResult import com.tangem.common.CompletionResult +import com.tangem.common.extensions.toCompressedPublicKey +import com.tangem.common.extensions.toHexString +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay import java.math.BigDecimal import java.math.BigInteger -class KaspaWalletManager( +@Suppress("LargeClass") +internal class KaspaWalletManager( wallet: Wallet, private val transactionBuilder: KaspaTransactionBuilder, private val networkProvider: KaspaNetworkProvider, -) : WalletManager(wallet), UtxoAmountLimitProvider, UtxoBlockchainManager { + private val krc20NetworkProvider: KaspaKRC20NetworkProvider, + private val dataStorage: AdvancedDataStorage, +) : WalletManager(wallet), UtxoAmountLimitProvider, UtxoBlockchainManager, AssetRequirementsManager { override val currentHost: String get() = networkProvider.baseUrl @@ -30,9 +48,25 @@ class KaspaWalletManager( override val allowConsolidation: Boolean = true override suspend fun updateInternal() { - when (val response = networkProvider.getInfo(wallet.address)) { - is Result.Success -> updateWallet(response.data) - is Result.Failure -> updateError(response.error) + coroutineScope { + val coinBalanceDeferred = async { networkProvider.getInfo(wallet.address) } + + val tokensBalances = if (cardTokens.isNotEmpty()) { + async { krc20NetworkProvider.getBalances(wallet.address, cardTokens.toList()) }.await() + } else { + Result.Success(emptyList()) + } + + val coinBalance = coinBalanceDeferred.await() + + if (tokensBalances is Result.Success) { + updateWalletTokens(tokensBalances.data) + } + + when (coinBalance) { + is Result.Success -> updateWallet(coinBalance.data) + is Result.Failure -> updateError(coinBalance.error) + } } } @@ -46,6 +80,14 @@ class KaspaWalletManager( transactionBuilder.unspentOutputs = response.unspentOutputs } + private fun updateWalletTokens(tokensInfo: List) { + tokensInfo.forEach { result -> + val token = result.token + val balance = result.balance + wallet.setAmount(balance, amountType = AmountType.Token(token)) + } + } + private fun updateError(error: BlockchainError) { Log.e(this::class.java.simpleName, error.customMessage) if (error is BlockchainSdkError) throw error @@ -55,30 +97,22 @@ class KaspaWalletManager( transactionData: TransactionData, signer: TransactionSigner, ): Result { - when (val buildTransactionResult = transactionBuilder.buildToSign(transactionData)) { - is Result.Failure -> return buildTransactionResult - is Result.Success -> { - return when (val signerResult = signer.sign(buildTransactionResult.data, wallet.publicKey)) { - is CompletionResult.Success -> { - val transactionToSend = transactionBuilder.buildToSend( - signerResult.data.reduce { acc, bytes -> acc + bytes }, - ) - when (val sendResult = networkProvider.sendTransaction(transactionToSend)) { - is Result.Failure -> sendResult - is Result.Success -> { - val hash = sendResult.data - transactionData.hash = hash - wallet.addOutgoingTransaction(transactionData) - Result.Success(TransactionSendResult(hash ?: "")) - } - } - } - is CompletionResult.Failure -> Result.fromTangemSdkError(signerResult.error) + transactionData.requireUncompiled() + + return when (transactionData.amount.type) { + is AmountType.Coin -> sendCoinTransaction(transactionData, signer) + is AmountType.Token -> { + if (transactionData.extras as? KaspaKRC20TransactionExtras != null) { + sendKRC20RevealOnlyTransaction(transactionData, signer) + } else { + sendKRC20Transaction(transactionData, signer) } } + else -> error("unknown amount type for fee estimation") } } + @Suppress("CyclomaticComplexMethod", "NestedBlockDepth") override suspend fun getFee(amount: Amount, destination: String): Result { val unspentOutputCount = transactionBuilder.getUnspentsToSpendCount() @@ -94,19 +128,40 @@ class KaspaWalletManager( fee = null, ) - when (val buildTransactionResult = transactionBuilder.buildToSign(transactionData)) { + val buildTransactionResult = when (amount.type) { + is AmountType.Coin -> transactionBuilder.buildToSign(transactionData) + is AmountType.Token -> transactionBuilder.buildKRC20CommitTransactionToSign( + transactionData = transactionData, + dust = dustValue, + ).let { + when (it) { + is Result.Failure -> it + is Result.Success -> Result.Success(it.data.transaction) + } + } + else -> error("unknown amount type for fee estimation") + } + + when (buildTransactionResult) { is Result.Failure -> return buildTransactionResult is Result.Success -> { - return when (val signerResult = dummySigner.sign(buildTransactionResult.data, wallet.publicKey)) { + val transaction = buildTransactionResult.data + val hashesToSign = transactionBuilder.getHashesForSign(transaction) + return when (val signerResult = dummySigner.sign(hashesToSign, wallet.publicKey)) { is CompletionResult.Success -> { val transactionToSend = transactionBuilder.buildToSend( - signerResult.data.reduce { acc, bytes -> acc + bytes }, + signatures = signerResult.data.reduce { acc, bytes -> acc + bytes }, + transaction = transaction, ) when (val sendResult = networkProvider.calculateFee(transactionToSend.transaction)) { is Result.Failure -> sendResult is Result.Success -> { val data = sendResult.data - val mass = BigInteger.valueOf(data.mass) + val mass = when (amount.type) { + is AmountType.Coin -> BigInteger.valueOf(data.mass) + is AmountType.Token -> REVEAL_TRANSACTION_MASS + else -> error("unknown amount type for fee estimation") + } val allBuckets = ( listOf(data.priorityBucket) + @@ -116,9 +171,9 @@ class KaspaWalletManager( Result.Success( TransactionFee.Choosable( - priority = allBuckets[0].toFee(mass), - normal = allBuckets[1].toFee(mass), - minimum = allBuckets[2].toFee(mass), + priority = allBuckets[0].toFee(mass, amount.type), + normal = allBuckets[1].toFee(mass, amount.type), + minimum = allBuckets[2].toFee(mass, amount.type), ), ) } @@ -144,16 +199,313 @@ class KaspaWalletManager( } } - private fun KaspaFeeBucketResponse.toFee(mass: BigInteger): Fee.Kaspa { + override suspend fun requirementsCondition(currencyType: CryptoCurrencyType): AssetRequirementsCondition? { + return when (currencyType) { + is CryptoCurrencyType.Coin -> null + is CryptoCurrencyType.Token -> { + getIncompleteTokenTransaction(currencyType.info)?.let { + AssetRequirementsCondition.IncompleteTransaction( + blockchain = blockchain, + amount = Amount( + value = it.amountValue, + token = currencyType.info, + ), + feeAmount = Amount( + value = it.feeAmountValue, + blockchain = blockchain, + ), + ) + } + } + } + } + + override suspend fun fulfillRequirements( + currencyType: CryptoCurrencyType, + signer: TransactionSigner, + ): SimpleResult { + return when (currencyType) { + is CryptoCurrencyType.Coin -> SimpleResult.Success + is CryptoCurrencyType.Token -> { + val incompleteTokenTransaction = getIncompleteTokenTransaction(currencyType.info) + ?: return SimpleResult.Success + + val result = sendKRC20RevealOnlyTransaction( + transactionData = incompleteTokenTransaction + .toIncompleteTokenTransactionParams() + .toTransactionData( + type = AmountType.Token(currencyType.info), + ), + signer = signer, + ) + + when (result) { + is Result.Success -> SimpleResult.Success + is Result.Failure -> SimpleResult.Failure(result.error) + } + } + } + } + + override suspend fun discardRequirements(currencyType: CryptoCurrencyType): SimpleResult { + when (currencyType) { + is CryptoCurrencyType.Coin -> Unit + is CryptoCurrencyType.Token -> { + removeIncompleteTokenTransaction(currencyType.info) + } + } + return SimpleResult.Success + } + + private suspend fun sendCoinTransaction( + transactionData: TransactionData, + signer: TransactionSigner, + ): Result { + when (val buildTransactionResult = transactionBuilder.buildToSign(transactionData)) { + is Result.Failure -> return buildTransactionResult + is Result.Success -> { + val transaction = buildTransactionResult.data + val hashesToSign = transactionBuilder.getHashesForSign(transaction) + return when (val signerResult = signer.sign(hashesToSign, wallet.publicKey)) { + is CompletionResult.Success -> { + val transactionToSend = transactionBuilder.buildToSend( + signatures = signerResult.data.reduce { acc, bytes -> acc + bytes }, + transaction = transaction, + ) + when (val sendResult = networkProvider.sendTransaction(transactionToSend)) { + is Result.Failure -> sendResult + is Result.Success -> { + val hash = sendResult.data + transactionData.hash = hash + wallet.addOutgoingTransaction(transactionData) + Result.Success(TransactionSendResult(hash ?: "")) + } + } + } + is CompletionResult.Failure -> Result.fromTangemSdkError(signerResult.error) + } + } + } + } + + @Suppress("NestedBlockDepth", "LongMethod") + private suspend fun sendKRC20Transaction( + transactionData: TransactionData, + signer: TransactionSigner, + ): Result { + transactionData.requireUncompiled() + val token = (transactionData.amount.type as AmountType.Token).token + return when ( + val commitTransaction = transactionBuilder.buildKRC20CommitTransactionToSign( + transactionData = transactionData, + dust = dustValue, + ) + ) { + is Result.Success -> { + val revealTransaction = transactionBuilder.buildKRC20RevealTransactionToSign( + sourceAddress = transactionData.sourceAddress, + redeemScript = commitTransaction.data.redeemScript, + feeAmountValue = transactionData.fee?.amount?.value!!, + params = commitTransaction.data.params, + ) + + when (revealTransaction) { + is Result.Success -> { + val unionHashes = commitTransaction.data.hashes + revealTransaction.data.hashes + + return when (val signerResult = signer.sign(unionHashes, wallet.publicKey)) { + is CompletionResult.Success -> { + val commitSignaturesLength = commitTransaction.data.hashes.size + val commitSignatures = signerResult.data.take(commitSignaturesLength) + val revealSignatures = signerResult.data.drop(commitSignaturesLength) + val commitTransactionToSend = transactionBuilder.buildToSend( + signatures = commitSignatures.reduce { acc, bytes -> acc + bytes }, + transaction = commitTransaction.data.transaction, + ) + val revealTransactionToSend = transactionBuilder.buildKRC20RevealToSend( + signatures = revealSignatures.reduce { acc, bytes -> acc + bytes }, + redeemScript = commitTransaction.data.redeemScript, + transaction = revealTransaction.data.transaction, + ) + when ( + val sendCommitResult = networkProvider.sendTransaction(commitTransactionToSend) + ) { + is Result.Failure -> sendCommitResult + is Result.Success -> { + storeIncompleteTokenTransaction( + token = token, + data = commitTransaction.data + .params + .toBlockchainSavedData(), + ) + delay(REVEAL_TRANSACTION_DELAY) + + when ( + val sendRevealResult = networkProvider.sendTransaction( + revealTransactionToSend, + ) + ) { + is Result.Failure -> sendRevealResult + is Result.Success -> { + val hash = sendRevealResult.data + transactionData.hash = hash + wallet.addOutgoingTransaction(transactionData) + removeIncompleteTokenTransaction(token) + Result.Success(TransactionSendResult(hash ?: "")) + } + } + } + } + } + is CompletionResult.Failure -> Result.fromTangemSdkError(signerResult.error) + } + } + is Result.Failure -> revealTransaction + } + } + is Result.Failure -> commitTransaction + } + } + + private fun IncompleteTokenTransactionParams.toTransactionData(type: AmountType.Token): TransactionData.Uncompiled { + val token = type.token + val tokenValue = BigDecimal(envelope.amt) + + val transactionAmount = tokenValue.movePointLeft(token.decimals) + val fee = feeAmountValue - dustValue + val feeAmount = Amount( + value = fee, + blockchain = blockchain, + ) + + return TransactionData.Uncompiled( + amount = Amount( + value = transactionAmount, + blockchain = blockchain, + type = type, + ), + fee = Fee.Kaspa( + amount = feeAmount, + mass = BigInteger.ZERO, // we need only amount + feeRate = BigInteger.ZERO, // we need only amount + revealTransactionFee = feeAmount, + ), + sourceAddress = wallet.address, + destinationAddress = envelope.to, + status = TransactionStatus.Unconfirmed, + extras = KaspaKRC20TransactionExtras( + incompleteTokenTransactionParams = this, + ), + contractAddress = token.contractAddress, + ) + } + + private suspend fun sendKRC20RevealOnlyTransaction( + transactionData: TransactionData, + signer: TransactionSigner, + ): Result { + transactionData.requireUncompiled() + val token = (transactionData.amount.type as AmountType.Token).token + val incompleteTokenTransactionParams = + (transactionData.extras as KaspaKRC20TransactionExtras).incompleteTokenTransactionParams + val feeAmount = (transactionData.fee as Fee.Kaspa).revealTransactionFee + val redeemScript = RedeemScript( + wallet.publicKey.blockchainKey.toCompressedPublicKey(), + incompleteTokenTransactionParams.envelope, + ) + val transaction = transactionBuilder.buildKRC20RevealTransactionToSign( + sourceAddress = transactionData.sourceAddress, + redeemScript = redeemScript, + params = incompleteTokenTransactionParams, + feeAmountValue = feeAmount?.value!!, + ) + return when (transaction) { + is Result.Success -> { + return when (val signerResult = signer.sign(transaction.data.hashes, wallet.publicKey)) { + is CompletionResult.Success -> { + val transactionToSend = transactionBuilder.buildKRC20RevealToSend( + signatures = signerResult.data.reduce { acc, bytes -> acc + bytes }, + redeemScript = redeemScript, + transaction = transaction.data.transaction, + ) + when (val sendResult = networkProvider.sendTransaction(transactionToSend)) { + is Result.Failure -> sendResult + is Result.Success -> { + val hash = sendResult.data + transactionData.hash = hash + wallet.addOutgoingTransaction(transactionData) + removeIncompleteTokenTransaction(token) + Result.Success(TransactionSendResult(hash ?: "")) + } + } + } + is CompletionResult.Failure -> Result.fromTangemSdkError(signerResult.error) + } + } + is Result.Failure -> transaction + } + } + + private fun KaspaFeeBucketResponse.toFee(mass: BigInteger, type: AmountType): Fee.Kaspa { val feeRate = feeRate.toBigInteger() val value = (mass * feeRate).toBigDecimal().movePointLeft(blockchain.decimals()) return Fee.Kaspa( amount = Amount( value = value, blockchain = blockchain, + type = type, ), mass = mass, feeRate = feeRate, + revealTransactionFee = type.takeIf { it is AmountType.Token }.let { + Amount( + value = (mass * feeRate).toBigDecimal().movePointLeft(blockchain.decimals()), + blockchain = blockchain, + type = type, + ) + }, ) } + + private suspend fun getIncompleteTokenTransaction( + token: Token, + ): BlockchainSavedData.KaspaKRC20IncompleteTokenTransaction? { + return dataStorage.getOrNull(token.createKey()) + } + + private suspend fun storeIncompleteTokenTransaction( + token: Token, + data: BlockchainSavedData.KaspaKRC20IncompleteTokenTransaction, + ) { + dataStorage.store(token.createKey(), data) + } + + private suspend fun removeIncompleteTokenTransaction(token: Token) { + dataStorage.remove(token.createKey()) + } + + private fun Token.createKey(): String { + return "$symbol-${wallet.publicKey.blockchainKey.toCompressedPublicKey().toHexString()}" + } + + private fun IncompleteTokenTransactionParams.toBlockchainSavedData() = + BlockchainSavedData.KaspaKRC20IncompleteTokenTransaction( + transactionId = transactionId, + amountValue = amountValue, + feeAmountValue = feeAmountValue, + envelope = envelope, + ) + + private fun BlockchainSavedData.KaspaKRC20IncompleteTokenTransaction.toIncompleteTokenTransactionParams() = + IncompleteTokenTransactionParams( + transactionId = transactionId, + amountValue = amountValue, + feeAmountValue = feeAmountValue, + envelope = envelope, + ) + + companion object { + private val REVEAL_TRANSACTION_MASS: BigInteger = 4100.toBigInteger() + private const val REVEAL_TRANSACTION_DELAY: Long = 2_000 + } } diff --git a/blockchain/src/main/java/com/tangem/blockchain/blockchains/kaspa/krc20/KaspaKRC20Api.kt b/blockchain/src/main/java/com/tangem/blockchain/blockchains/kaspa/krc20/KaspaKRC20Api.kt new file mode 100644 index 000000000..798dd2316 --- /dev/null +++ b/blockchain/src/main/java/com/tangem/blockchain/blockchains/kaspa/krc20/KaspaKRC20Api.kt @@ -0,0 +1,8 @@ +package com.tangem.blockchain.blockchains.kaspa.krc20 + +import retrofit2.http.* + +interface KaspaKRC20Api { + @GET("krc20/address/{address}/token/{token}") + suspend fun getBalance(@Path("address") address: String, @Path("token") token: String): KaspaKRC20BalanceResponse +} diff --git a/blockchain/src/main/java/com/tangem/blockchain/blockchains/kaspa/krc20/KaspaKRC20BalanceResponse.kt b/blockchain/src/main/java/com/tangem/blockchain/blockchains/kaspa/krc20/KaspaKRC20BalanceResponse.kt new file mode 100644 index 000000000..289c6606d --- /dev/null +++ b/blockchain/src/main/java/com/tangem/blockchain/blockchains/kaspa/krc20/KaspaKRC20BalanceResponse.kt @@ -0,0 +1,16 @@ +package com.tangem.blockchain.blockchains.kaspa.krc20 + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class KaspaKRC20BalanceResponse( + @Json(name = "result") + val result: List = emptyList(), +) { + @JsonClass(generateAdapter = true) + data class TokenBalance( + @Json(name = "balance") + val balance: Long? = null, + ) +} diff --git a/blockchain/src/main/java/com/tangem/blockchain/blockchains/kaspa/krc20/KaspaKRC20NetworkProvider.kt b/blockchain/src/main/java/com/tangem/blockchain/blockchains/kaspa/krc20/KaspaKRC20NetworkProvider.kt new file mode 100644 index 000000000..5db956a49 --- /dev/null +++ b/blockchain/src/main/java/com/tangem/blockchain/blockchains/kaspa/krc20/KaspaKRC20NetworkProvider.kt @@ -0,0 +1,15 @@ +package com.tangem.blockchain.blockchains.kaspa.krc20 + +import com.tangem.blockchain.common.NetworkProvider +import com.tangem.blockchain.common.Token +import com.tangem.blockchain.extensions.Result +import java.math.BigDecimal + +interface KaspaKRC20NetworkProvider : NetworkProvider { + suspend fun getBalances(address: String, tokens: List): Result> +} + +data class KaspaKRC20InfoResponse( + val token: Token, + val balance: BigDecimal, +) diff --git a/blockchain/src/main/java/com/tangem/blockchain/blockchains/kaspa/krc20/KaspaKRC20NetworkService.kt b/blockchain/src/main/java/com/tangem/blockchain/blockchains/kaspa/krc20/KaspaKRC20NetworkService.kt new file mode 100644 index 000000000..75c734c8a --- /dev/null +++ b/blockchain/src/main/java/com/tangem/blockchain/blockchains/kaspa/krc20/KaspaKRC20NetworkService.kt @@ -0,0 +1,16 @@ +package com.tangem.blockchain.blockchains.kaspa.krc20 + +import com.tangem.blockchain.common.Token +import com.tangem.blockchain.extensions.Result +import com.tangem.blockchain.network.MultiNetworkProvider + +class KaspaKRC20NetworkService(providers: List) : KaspaKRC20NetworkProvider { + + private val multiNetworkProvider = MultiNetworkProvider(providers) + override val baseUrl: String + get() = multiNetworkProvider.currentProvider.baseUrl + + override suspend fun getBalances(address: String, tokens: List): Result> { + return multiNetworkProvider.performRequest { getBalances(address, tokens) } + } +} diff --git a/blockchain/src/main/java/com/tangem/blockchain/blockchains/kaspa/krc20/KaspaKRC20RestApiNetworkProvider.kt b/blockchain/src/main/java/com/tangem/blockchain/blockchains/kaspa/krc20/KaspaKRC20RestApiNetworkProvider.kt new file mode 100644 index 000000000..33d7e84a3 --- /dev/null +++ b/blockchain/src/main/java/com/tangem/blockchain/blockchains/kaspa/krc20/KaspaKRC20RestApiNetworkProvider.kt @@ -0,0 +1,45 @@ +package com.tangem.blockchain.blockchains.kaspa.krc20 + +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchain.common.Token +import com.tangem.blockchain.common.toBlockchainSdkError +import com.tangem.blockchain.extensions.Result +import com.tangem.blockchain.extensions.retryIO +import com.tangem.blockchain.network.createRetrofitInstance +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope + +open class KaspaKRC20RestApiNetworkProvider(override val baseUrl: String) : KaspaKRC20NetworkProvider { + + private val api: KaspaKRC20Api by lazy { + createRetrofitInstance(baseUrl).create(KaspaKRC20Api::class.java) + } + private val decimals = Blockchain.Kaspa.decimals() + + override suspend fun getBalances(address: String, tokens: List): Result> { + return try { + coroutineScope { + val tokenBalancesDeferred = tokens.associateWith { token -> + async { retryIO { api.getBalance(address, token.contractAddress).result.first() } } + } + + val tokenBalanceResponses = tokenBalancesDeferred.mapValues { it.value.await() } + + Result.Success( + tokenBalanceResponses.map { + try { + KaspaKRC20InfoResponse( + token = it.key, + balance = it.value.balance!!.toBigDecimal().movePointLeft(decimals), + ) + } catch (exception: Exception) { + throw exception.toBlockchainSdkError() + } + }, + ) + } + } catch (exception: Exception) { + Result.Failure(exception.toBlockchainSdkError()) + } + } +} diff --git a/blockchain/src/main/java/com/tangem/blockchain/blockchains/kaspa/krc20/KaspaKRC20TransactionExtras.kt b/blockchain/src/main/java/com/tangem/blockchain/blockchains/kaspa/krc20/KaspaKRC20TransactionExtras.kt new file mode 100644 index 000000000..fc864adb2 --- /dev/null +++ b/blockchain/src/main/java/com/tangem/blockchain/blockchains/kaspa/krc20/KaspaKRC20TransactionExtras.kt @@ -0,0 +1,8 @@ +package com.tangem.blockchain.blockchains.kaspa.krc20 + +import com.tangem.blockchain.blockchains.kaspa.krc20.model.IncompleteTokenTransactionParams +import com.tangem.blockchain.common.TransactionExtras + +internal data class KaspaKRC20TransactionExtras( + val incompleteTokenTransactionParams: IncompleteTokenTransactionParams, +) : TransactionExtras diff --git a/blockchain/src/main/java/com/tangem/blockchain/blockchains/kaspa/krc20/model/CommitTransaction.kt b/blockchain/src/main/java/com/tangem/blockchain/blockchains/kaspa/krc20/model/CommitTransaction.kt new file mode 100644 index 000000000..659a5ebbc --- /dev/null +++ b/blockchain/src/main/java/com/tangem/blockchain/blockchains/kaspa/krc20/model/CommitTransaction.kt @@ -0,0 +1,11 @@ +package com.tangem.blockchain.blockchains.kaspa.krc20.model + +import com.tangem.blockchain.blockchains.kaspa.KaspaTransaction + +internal data class CommitTransaction( + val transaction: KaspaTransaction, + val hashes: List, + val redeemScript: RedeemScript, + val sourceAddress: String, + val params: IncompleteTokenTransactionParams, +) diff --git a/blockchain/src/main/java/com/tangem/blockchain/blockchains/kaspa/krc20/model/Envelope.kt b/blockchain/src/main/java/com/tangem/blockchain/blockchains/kaspa/krc20/model/Envelope.kt new file mode 100644 index 000000000..da6456594 --- /dev/null +++ b/blockchain/src/main/java/com/tangem/blockchain/blockchains/kaspa/krc20/model/Envelope.kt @@ -0,0 +1,13 @@ +package com.tangem.blockchain.blockchains.kaspa.krc20.model + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +internal data class Envelope( + @Json(name = "p") val p: String, + @Json(name = "op") val op: String, + @Json(name = "amt") val amt: String, + @Json(name = "to") val to: String, + @Json(name = "tick") val tick: String, +) diff --git a/blockchain/src/main/java/com/tangem/blockchain/blockchains/kaspa/krc20/model/IncompleteTokenTransactionParams.kt b/blockchain/src/main/java/com/tangem/blockchain/blockchains/kaspa/krc20/model/IncompleteTokenTransactionParams.kt new file mode 100644 index 000000000..bf078cc8e --- /dev/null +++ b/blockchain/src/main/java/com/tangem/blockchain/blockchains/kaspa/krc20/model/IncompleteTokenTransactionParams.kt @@ -0,0 +1,10 @@ +package com.tangem.blockchain.blockchains.kaspa.krc20.model + +import java.math.BigDecimal + +internal data class IncompleteTokenTransactionParams( + val transactionId: String, + val amountValue: BigDecimal, + val feeAmountValue: BigDecimal, + val envelope: Envelope, +) diff --git a/blockchain/src/main/java/com/tangem/blockchain/blockchains/kaspa/krc20/model/KaspaKRC20ProvidersBuilder.kt b/blockchain/src/main/java/com/tangem/blockchain/blockchains/kaspa/krc20/model/KaspaKRC20ProvidersBuilder.kt new file mode 100644 index 000000000..bc82ac8a5 --- /dev/null +++ b/blockchain/src/main/java/com/tangem/blockchain/blockchains/kaspa/krc20/model/KaspaKRC20ProvidersBuilder.kt @@ -0,0 +1,20 @@ +package com.tangem.blockchain.blockchains.kaspa.krc20.model + +import com.tangem.blockchain.blockchains.kaspa.krc20.KaspaKRC20NetworkProvider +import com.tangem.blockchain.blockchains.kaspa.krc20.KaspaKRC20RestApiNetworkProvider +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchain.common.network.providers.NetworkProvidersBuilder +import com.tangem.blockchain.common.network.providers.ProviderType + +internal class KaspaKRC20ProvidersBuilder( + override val providerTypes: List, +) : NetworkProvidersBuilder() { + + override fun createProviders(blockchain: Blockchain): List { + return listOf(KaspaKRC20RestApiNetworkProvider("https://api.kasplex.org/v1/")) + } + + override fun createTestnetProviders(blockchain: Blockchain): List { + return listOf(KaspaKRC20RestApiNetworkProvider("https://tn10api.kasplex.org/v1")) + } +} diff --git a/blockchain/src/main/java/com/tangem/blockchain/blockchains/kaspa/krc20/model/RedeemScript.kt b/blockchain/src/main/java/com/tangem/blockchain/blockchains/kaspa/krc20/model/RedeemScript.kt new file mode 100644 index 000000000..380850844 --- /dev/null +++ b/blockchain/src/main/java/com/tangem/blockchain/blockchains/kaspa/krc20/model/RedeemScript.kt @@ -0,0 +1,6 @@ +package com.tangem.blockchain.blockchains.kaspa.krc20.model + +internal data class RedeemScript( + val publicKey: ByteArray, + val envelope: Envelope, +) diff --git a/blockchain/src/main/java/com/tangem/blockchain/blockchains/kaspa/krc20/model/RevealTransaction.kt b/blockchain/src/main/java/com/tangem/blockchain/blockchains/kaspa/krc20/model/RevealTransaction.kt new file mode 100644 index 000000000..a6eca070d --- /dev/null +++ b/blockchain/src/main/java/com/tangem/blockchain/blockchains/kaspa/krc20/model/RevealTransaction.kt @@ -0,0 +1,9 @@ +package com.tangem.blockchain.blockchains.kaspa.krc20.model + +import com.tangem.blockchain.blockchains.kaspa.KaspaTransaction + +internal data class RevealTransaction( + val transaction: KaspaTransaction, + val hashes: List, + val redeemScript: RedeemScript, +) diff --git a/blockchain/src/main/java/com/tangem/blockchain/common/Blockchain.kt b/blockchain/src/main/java/com/tangem/blockchain/common/Blockchain.kt index 1259710e0..d862b0f1d 100644 --- a/blockchain/src/main/java/com/tangem/blockchain/common/Blockchain.kt +++ b/blockchain/src/main/java/com/tangem/blockchain/common/Blockchain.kt @@ -684,6 +684,7 @@ enum class Blockchain( Hedera, HederaTestnet, TON, TONTestnet, Cardano, + Kaspa, -> true else -> false diff --git a/blockchain/src/main/java/com/tangem/blockchain/common/CryptoCurrencyType.kt b/blockchain/src/main/java/com/tangem/blockchain/common/CryptoCurrencyType.kt index 4bb6442cb..800a03447 100644 --- a/blockchain/src/main/java/com/tangem/blockchain/common/CryptoCurrencyType.kt +++ b/blockchain/src/main/java/com/tangem/blockchain/common/CryptoCurrencyType.kt @@ -3,6 +3,6 @@ package com.tangem.blockchain.common import com.tangem.blockchain.common.Token as BlockchainSdkToken sealed class CryptoCurrencyType { - object Coin : CryptoCurrencyType() + data object Coin : CryptoCurrencyType() data class Token(val info: BlockchainSdkToken) : CryptoCurrencyType() } diff --git a/blockchain/src/main/java/com/tangem/blockchain/common/WalletManagerFactory.kt b/blockchain/src/main/java/com/tangem/blockchain/common/WalletManagerFactory.kt index c243b0394..abde33d03 100644 --- a/blockchain/src/main/java/com/tangem/blockchain/common/WalletManagerFactory.kt +++ b/blockchain/src/main/java/com/tangem/blockchain/common/WalletManagerFactory.kt @@ -184,7 +184,7 @@ class WalletManagerFactory( Blockchain.Binance, Blockchain.BinanceTestnet -> BinanceWalletManagerAssembly Blockchain.Tezos -> TezosWalletManagerAssembly Blockchain.Tron, Blockchain.TronTestnet -> TronWalletManagerAssembly - Blockchain.Kaspa -> KaspaWalletManagerAssembly + Blockchain.Kaspa -> KaspaWalletManagerAssembly(dataStorage) Blockchain.TON, Blockchain.TONTestnet -> TonWalletManagerAssembly Blockchain.Cosmos, Blockchain.CosmosTestnet -> CosmosWalletManagerAssembly Blockchain.TerraV1 -> TerraV1WalletManagerAssembly diff --git a/blockchain/src/main/java/com/tangem/blockchain/common/assembly/impl/KaspaWalletManagerAssembly.kt b/blockchain/src/main/java/com/tangem/blockchain/common/assembly/impl/KaspaWalletManagerAssembly.kt index bd34af174..d97f92475 100644 --- a/blockchain/src/main/java/com/tangem/blockchain/common/assembly/impl/KaspaWalletManagerAssembly.kt +++ b/blockchain/src/main/java/com/tangem/blockchain/common/assembly/impl/KaspaWalletManagerAssembly.kt @@ -3,20 +3,31 @@ package com.tangem.blockchain.common.assembly.impl import com.tangem.blockchain.blockchains.kaspa.KaspaProvidersBuilder import com.tangem.blockchain.blockchains.kaspa.KaspaTransactionBuilder import com.tangem.blockchain.blockchains.kaspa.KaspaWalletManager +import com.tangem.blockchain.blockchains.kaspa.krc20.KaspaKRC20NetworkService +import com.tangem.blockchain.blockchains.kaspa.krc20.model.KaspaKRC20ProvidersBuilder import com.tangem.blockchain.blockchains.kaspa.network.KaspaNetworkService import com.tangem.blockchain.common.assembly.WalletManagerAssembly import com.tangem.blockchain.common.assembly.WalletManagerAssemblyInput +import com.tangem.blockchain.common.datastorage.implementations.AdvancedDataStorage -internal object KaspaWalletManagerAssembly : WalletManagerAssembly() { +internal class KaspaWalletManagerAssembly( + private val dataStorage: AdvancedDataStorage, +) : WalletManagerAssembly() { override fun make(input: WalletManagerAssemblyInput): KaspaWalletManager { return with(input.wallet) { KaspaWalletManager( wallet = this, - transactionBuilder = KaspaTransactionBuilder(), + transactionBuilder = KaspaTransactionBuilder( + publicKey = publicKey, + ), networkProvider = KaspaNetworkService( providers = KaspaProvidersBuilder(input.providerTypes, input.config).build(blockchain), ), + krc20NetworkProvider = KaspaKRC20NetworkService( + providers = KaspaKRC20ProvidersBuilder(input.providerTypes).build(blockchain), + ), + dataStorage = dataStorage, ) } } diff --git a/blockchain/src/main/java/com/tangem/blockchain/common/datastorage/BlockchainDataStorage.kt b/blockchain/src/main/java/com/tangem/blockchain/common/datastorage/BlockchainDataStorage.kt index 480554118..2f26369bb 100644 --- a/blockchain/src/main/java/com/tangem/blockchain/common/datastorage/BlockchainDataStorage.kt +++ b/blockchain/src/main/java/com/tangem/blockchain/common/datastorage/BlockchainDataStorage.kt @@ -12,4 +12,7 @@ interface BlockchainDataStorage { /** Store [value] in JSON format by [key] */ suspend fun store(key: String, value: String) + + /** Remove [value] from storage by [key] */ + suspend fun remove(key: String) } diff --git a/blockchain/src/main/java/com/tangem/blockchain/common/datastorage/BlockchainSavedData.kt b/blockchain/src/main/java/com/tangem/blockchain/common/datastorage/BlockchainSavedData.kt index c05dfffb1..402a7a529 100644 --- a/blockchain/src/main/java/com/tangem/blockchain/common/datastorage/BlockchainSavedData.kt +++ b/blockchain/src/main/java/com/tangem/blockchain/common/datastorage/BlockchainSavedData.kt @@ -2,6 +2,8 @@ package com.tangem.blockchain.common.datastorage import com.squareup.moshi.Json import com.squareup.moshi.JsonClass +import com.tangem.blockchain.blockchains.kaspa.krc20.model.Envelope +import java.math.BigDecimal internal sealed interface BlockchainSavedData { @@ -16,4 +18,12 @@ internal sealed interface BlockchainSavedData { // TODO: Remove this flag in future https://tangem.atlassian.net/browse/AND-7025 @Json(name = "cache_cleared") val isCacheCleared: Boolean = false, ) : BlockchainSavedData + + @JsonClass(generateAdapter = true) + data class KaspaKRC20IncompleteTokenTransaction( + @Json(name = "transactionId") val transactionId: String, + @Json(name = "amountValue") val amountValue: BigDecimal, + @Json(name = "feeAmountValue") val feeAmountValue: BigDecimal, + @Json(name = "envelope") val envelope: Envelope, + ) : BlockchainSavedData } diff --git a/blockchain/src/main/java/com/tangem/blockchain/common/datastorage/implementations/AdvancedDataStorage.kt b/blockchain/src/main/java/com/tangem/blockchain/common/datastorage/implementations/AdvancedDataStorage.kt index e0db341f3..3429e6d3d 100644 --- a/blockchain/src/main/java/com/tangem/blockchain/common/datastorage/implementations/AdvancedDataStorage.kt +++ b/blockchain/src/main/java/com/tangem/blockchain/common/datastorage/implementations/AdvancedDataStorage.kt @@ -43,6 +43,17 @@ internal class AdvancedDataStorage( blockchainDataStorage.store(key = T::class.java.createKey(publicKey), value = adapter.toJson(value)) } + /** Store [value] by [key] */ + suspend inline fun store(key: String, value: T) { + val adapter = moshi.adapter(T::class.java) + blockchainDataStorage.store(key = key, value = adapter.toJson(value)) + } + + /** Remove [value] from storage by [key] */ + suspend inline fun remove(key: String) { + blockchainDataStorage.remove(key = key) + } + /** * Create a unique key by [publicKey] for [BlockchainSavedData]. * Example, Hedera-7BD63F5DE1BF539525C33367592949AE9B99D518BF78F26F3904BCD30CFCF018 diff --git a/blockchain/src/main/java/com/tangem/blockchain/common/transaction/Fee.kt b/blockchain/src/main/java/com/tangem/blockchain/common/transaction/Fee.kt index 8a8cf06aa..254bd092e 100644 --- a/blockchain/src/main/java/com/tangem/blockchain/common/transaction/Fee.kt +++ b/blockchain/src/main/java/com/tangem/blockchain/common/transaction/Fee.kt @@ -70,6 +70,7 @@ sealed class Fee { override val amount: Amount, val mass: BigInteger, val feeRate: BigInteger, + val revealTransactionFee: Amount? = null, ) : Fee() data class Filecoin( diff --git a/blockchain/src/main/java/com/tangem/blockchain/common/trustlines/AssetRequirementsCondition.kt b/blockchain/src/main/java/com/tangem/blockchain/common/trustlines/AssetRequirementsCondition.kt index f3529de0c..4ff907ceb 100644 --- a/blockchain/src/main/java/com/tangem/blockchain/common/trustlines/AssetRequirementsCondition.kt +++ b/blockchain/src/main/java/com/tangem/blockchain/common/trustlines/AssetRequirementsCondition.kt @@ -1,16 +1,26 @@ package com.tangem.blockchain.common.trustlines import com.tangem.blockchain.common.Amount +import com.tangem.blockchain.common.Blockchain sealed class AssetRequirementsCondition { /** * The exact value of the fee for this type of condition is unknown. */ - object PaidTransaction : AssetRequirementsCondition() + data object PaidTransaction : AssetRequirementsCondition() /** * The exact value of the fee for this type of condition is stored in `feeAmount`. */ - data class PaidTransactionWithFee(val feeAmount: Amount) : AssetRequirementsCondition() + data class PaidTransactionWithFee( + val blockchain: Blockchain, + val feeAmount: Amount, + ) : AssetRequirementsCondition() + + data class IncompleteTransaction( + val blockchain: Blockchain, + val amount: Amount, + val feeAmount: Amount, + ) : AssetRequirementsCondition() } diff --git a/blockchain/src/main/java/com/tangem/blockchain/common/trustlines/AssetRequirementsManager.kt b/blockchain/src/main/java/com/tangem/blockchain/common/trustlines/AssetRequirementsManager.kt index 27be094b1..e3107e6d3 100644 --- a/blockchain/src/main/java/com/tangem/blockchain/common/trustlines/AssetRequirementsManager.kt +++ b/blockchain/src/main/java/com/tangem/blockchain/common/trustlines/AssetRequirementsManager.kt @@ -4,14 +4,11 @@ import com.tangem.blockchain.common.CryptoCurrencyType import com.tangem.blockchain.common.TransactionSigner import com.tangem.blockchain.extensions.SimpleResult -/** - * Responsible for the token association creation (Hedera) and trust line setup (XRP, Stellar, Aptos, Algorand and other). - */ interface AssetRequirementsManager { - suspend fun hasRequirements(currencyType: CryptoCurrencyType): Boolean - suspend fun requirementsCondition(currencyType: CryptoCurrencyType): AssetRequirementsCondition? suspend fun fulfillRequirements(currencyType: CryptoCurrencyType, signer: TransactionSigner): SimpleResult + + suspend fun discardRequirements(currencyType: CryptoCurrencyType): SimpleResult } diff --git a/blockchain/src/test/java/com/tangem/blockchain/blockchains/kaspa/KaspaTransactionTest.kt b/blockchain/src/test/java/com/tangem/blockchain/blockchains/kaspa/KaspaTransactionTest.kt index 823d83bad..f010f0c13 100644 --- a/blockchain/src/test/java/com/tangem/blockchain/blockchains/kaspa/KaspaTransactionTest.kt +++ b/blockchain/src/test/java/com/tangem/blockchain/blockchains/kaspa/KaspaTransactionTest.kt @@ -2,18 +2,19 @@ package com.tangem.blockchain.blockchains.kaspa import com.google.common.truth.Truth import com.tangem.blockchain.blockchains.kaspa.network.* -import com.tangem.blockchain.common.Amount -import com.tangem.blockchain.common.AmountType -import com.tangem.blockchain.common.Blockchain -import com.tangem.blockchain.common.TransactionData +import com.tangem.blockchain.common.* import com.tangem.blockchain.common.transaction.Fee import com.tangem.blockchain.extensions.Result import com.tangem.common.extensions.hexToBytes +import org.bitcoinj.core.Coin +import org.bitcoinj.core.TransactionOutput import org.junit.Test +import java.math.BigDecimal class KaspaTransactionTest { private val blockchain = Blockchain.Kaspa + private val networkParameters = KaspaMainNetParams() private val decimals = blockchain.decimals() private val addressService = KaspaAddressService() @@ -37,7 +38,12 @@ class KaspaTransactionTest { val sourceAddress = addressService.makeAddress(walletPublicKey) - val transactionBuilder = KaspaTransactionBuilder() + val transactionBuilder = KaspaTransactionBuilder( + publicKey = Wallet.PublicKey( + seedKey = walletPublicKey, + derivationType = null, + ), + ) transactionBuilder.unspentOutputs = listOf( KaspaUnspentOutput( transactionHash = "deb88e7dd734437c6232a636085ef917d1d13cc549fe14749765508b2782f2fb".hexToBytes(), @@ -132,10 +138,11 @@ class KaspaTransactionTest { // act val buildToSignResult = transactionBuilder.buildToSign(transactionData) as Result.Success - val signedTransaction = transactionBuilder.buildToSend(signature) + val hashes = transactionBuilder.getHashesForSign(buildToSignResult.data) + val signedTransaction = transactionBuilder.buildToSend(signature, buildToSignResult.data) // assert - Truth.assertThat(buildToSignResult.data.map { it.toList() }) + Truth.assertThat(hashes.map { it.toList() }) .containsExactly(expectedHashToSign1, expectedHashToSign2, expectedHashToSign3) Truth.assertThat(signedTransaction).isEqualTo(expectedSignedTransaction) } @@ -158,7 +165,12 @@ class KaspaTransactionTest { val sourceAddress = addressService.makeAddress(walletPublicKey) - val transactionBuilder = KaspaTransactionBuilder() + val transactionBuilder = KaspaTransactionBuilder( + publicKey = Wallet.PublicKey( + seedKey = walletPublicKey, + derivationType = null, + ), + ) transactionBuilder.unspentOutputs = listOf( KaspaUnspentOutput( transactionHash = "ae96e819429e9da538e84cb213f62fbc8ad32e932d7c7f1fb9bd2fedf8fd7b4a".hexToBytes(), @@ -217,10 +229,49 @@ class KaspaTransactionTest { // act val buildToSignResult = transactionBuilder.buildToSign(transactionData) as Result.Success - val signedTransaction = transactionBuilder.buildToSend(signature) + val hashes = transactionBuilder.getHashesForSign(buildToSignResult.data) + val signedTransaction = transactionBuilder.buildToSend(signature, buildToSignResult.data) // assert - Truth.assertThat(buildToSignResult.data.map { it.toList() }).containsExactly(expectedHashToSign1) + Truth.assertThat(hashes.map { it.toList() }).containsExactly(expectedHashToSign1) Truth.assertThat(signedTransaction).isEqualTo(expectedSignedTransaction) } + + @Test + fun buildCorrectKaspaKRC20Transaction() { + val commitTransaction = createKaspaTransaction( + networkParameters = networkParameters, + unspentOutputs = listOf( + KaspaUnspentOutput( + transactionHash = "4DF1F7923708F6FA98F8D192CDB511666FC93C858D86FB7BC61BC7C13D54C9F4".hexToBytes(), + outputIndex = 2, + amount = BigDecimal.ZERO, + outputScript = "415BFC0DDE408A06EC6A39AE850986B49C2D0D5B83E47233B43012DE3AEDCECDE75EBC239008060BD50633E8E1AEBA891300CA74E8279DD591D8CEDA60609AFA6001".hexToBytes(), + ), + ), + transformer = { + it.addOutput( + TransactionOutput( + networkParameters, + null, + Coin.valueOf(500003000), + "AA207B1CFEE1AA9CB2AB4EFF9FF9593F88D3F0453F02E02790AC493F8EB712DCE17787".hexToBytes(), + ), + ) + it.addOutput( + TransactionOutput( + networkParameters, + null, + Coin.valueOf(3764387352), + "2035C82AA416591A1AFB84D10B6D225899F27CE6B51381C03B8CF104C3906258D3AC".hexToBytes(), + ), + ) + it + }, + ) + + Truth + .assertThat(commitTransaction.transactionHash()) + .isEqualTo("C2CB9D865F5085CD6F7F23365545C68D1EACA7E3CDE9D231A64812BE2C989A30".hexToBytes()) + } } diff --git a/blockchain/src/test/java/com/tangem/blockchain/common/WalletManagerFactoryTest.kt b/blockchain/src/test/java/com/tangem/blockchain/common/WalletManagerFactoryTest.kt index 1c2116423..f141db37d 100644 --- a/blockchain/src/test/java/com/tangem/blockchain/common/WalletManagerFactoryTest.kt +++ b/blockchain/src/test/java/com/tangem/blockchain/common/WalletManagerFactoryTest.kt @@ -145,6 +145,7 @@ internal class WalletManagerFactoryTest { blockchainDataStorage = object : BlockchainDataStorage { override suspend fun getOrNull(key: String): String? = null override suspend fun store(key: String, value: String) = Unit + override suspend fun remove(key: String) = Unit }, accountCreator = accountCreator, featureToggles = BlockchainFeatureToggles(isEthereumEIP1559Enabled = true), @@ -211,6 +212,7 @@ internal class WalletManagerFactoryTest { blockchainDataStorage = object : BlockchainDataStorage { override suspend fun getOrNull(key: String): String? = null override suspend fun store(key: String, value: String) = Unit + override suspend fun remove(key: String) = Unit }, accountCreator = accountCreator, featureToggles = BlockchainFeatureToggles(isEthereumEIP1559Enabled = true),