diff --git a/android/app/src/main/kotlin/com/gemwallet/android/di/DataModule.kt b/android/app/src/main/kotlin/com/gemwallet/android/di/DataModule.kt index c9c577732..5df734f7b 100644 --- a/android/app/src/main/kotlin/com/gemwallet/android/di/DataModule.kt +++ b/android/app/src/main/kotlin/com/gemwallet/android/di/DataModule.kt @@ -1,18 +1,13 @@ package com.gemwallet.android.di import com.gemwallet.android.application.fiat.coordinators.SyncFiatAssets -import com.gemwallet.android.blockchain.clients.bitcoin.BitcoinSignClient import com.gemwallet.android.blockchain.services.BroadcastService import com.gemwallet.android.blockchain.services.NodeStatusService import com.gemwallet.android.blockchain.services.SignClientProxy import com.gemwallet.android.blockchain.services.SignService import com.gemwallet.android.blockchain.services.SignerPreloaderProxy import com.gemwallet.android.cases.device.SyncDeviceInfo -import com.gemwallet.android.ext.available -import com.gemwallet.android.ext.toChainType import com.gemwallet.android.services.SyncService -import com.wallet.core.primitives.Chain -import com.wallet.core.primitives.ChainType import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -44,28 +39,7 @@ object DataModule { @Provides @Singleton - fun provideSignService(): SignClientProxy = SignClientProxy( - clients = Chain.available().mapNotNull { - when (it.toChainType()) { - ChainType.Bitcoin -> BitcoinSignClient(it) - - ChainType.Ethereum, - ChainType.Solana, - ChainType.Aptos, - ChainType.Sui, - ChainType.HyperCore, - ChainType.Near, - ChainType.Algorand, - ChainType.Stellar, - ChainType.Cosmos, - ChainType.Ton, - ChainType.Polkadot, - ChainType.Xrp, - ChainType.Cardano, - ChainType.Tron -> return@mapNotNull null - } - } + listOf(SignService()), - ) + fun provideSignService(): SignClientProxy = SignClientProxy(SignService()) @Singleton @Provides diff --git a/android/app/src/main/kotlin/com/gemwallet/android/di/InteractsModule.kt b/android/app/src/main/kotlin/com/gemwallet/android/di/InteractsModule.kt index 21abbba99..e3ef37eaf 100644 --- a/android/app/src/main/kotlin/com/gemwallet/android/di/InteractsModule.kt +++ b/android/app/src/main/kotlin/com/gemwallet/android/di/InteractsModule.kt @@ -7,6 +7,7 @@ import com.gemwallet.android.application.wallet_import.coordinators.SyncWalletIm import com.gemwallet.android.blockchain.operators.CreateAccountOperator import com.gemwallet.android.blockchain.operators.CreateWalletOperator import com.gemwallet.android.blockchain.operators.DeleteKeyStoreOperator +import com.gemwallet.android.blockchain.operators.GemstoneValidateAddressOperator import com.gemwallet.android.blockchain.operators.LoadPrivateDataOperator import com.gemwallet.android.blockchain.operators.LoadPrivateKeyOperator import com.gemwallet.android.blockchain.operators.StorePhraseOperator @@ -18,7 +19,6 @@ import com.gemwallet.android.blockchain.operators.walletcore.WCDeleteKeyStoreOpe import com.gemwallet.android.blockchain.operators.walletcore.WCLoadPrivateDataOperator import com.gemwallet.android.blockchain.operators.walletcore.WCLoadPrivateKeyOperator import com.gemwallet.android.blockchain.operators.walletcore.WCStorePhraseOperator -import com.gemwallet.android.blockchain.operators.walletcore.WCValidateAddressOperator import com.gemwallet.android.blockchain.operators.walletcore.WCValidatePhraseOperator import com.gemwallet.android.cases.device.SyncSubscription import com.gemwallet.android.cases.wallet.ImportWalletService @@ -41,7 +41,7 @@ object InteractsModule { @Singleton @Provides - fun provideValidateAddressInteract(): ValidateAddressOperator = WCValidateAddressOperator() + fun provideValidateAddressInteract(): ValidateAddressOperator = GemstoneValidateAddressOperator() @Singleton @Provides diff --git a/android/blockchain/src/androidTest/kotlin/com/gemwallet/android/blockchain/clients/algorand/TestAlgorandSigner.kt b/android/blockchain/src/androidTest/kotlin/com/gemwallet/android/blockchain/clients/algorand/TestAlgorandSigner.kt index f4760b3e7..3877cc85e 100644 --- a/android/blockchain/src/androidTest/kotlin/com/gemwallet/android/blockchain/clients/algorand/TestAlgorandSigner.kt +++ b/android/blockchain/src/androidTest/kotlin/com/gemwallet/android/blockchain/clients/algorand/TestAlgorandSigner.kt @@ -1,5 +1,7 @@ package com.gemwallet.android.blockchain.clients.algorand +import uniffi.gemstone.GemTransactionLoadMetadata + import com.gemwallet.android.blockchain.includeLibs import com.gemwallet.android.blockchain.services.SignService import com.gemwallet.android.ext.asset @@ -39,11 +41,7 @@ class TestAlgorandSigner { BigInteger.valueOf(10_000_000), DestinationAddress("GOZOAE6SH6XGGDRBQLZEDRITKMF5OLVJNACVRQBUEGFLBBR5I64A7QN63E"), ), - chainData = AlgorandChainData( - sequence = 46932581UL, - block = "wGHE2Pwdvd7S12BL5FaOP20EGYesN73ktiC1qzkkit8=", - chainId = "mainnet-v1.0", - ), + metadata = GemTransactionLoadMetadata.Algorand(sequence = 46932581UL, blockHash = "wGHE2Pwdvd7S12BL5FaOP20EGYesN73ktiC1qzkkit8=", chainId = "mainnet-v1.0"), finalAmount = BigInteger.valueOf(10_000_000), fee = Fee.Plain( priority = FeePriority.Normal, diff --git a/android/blockchain/src/androidTest/kotlin/com/gemwallet/android/blockchain/clients/aptos/TestAptosSigner.kt b/android/blockchain/src/androidTest/kotlin/com/gemwallet/android/blockchain/clients/aptos/TestAptosSigner.kt index 08e3ddb42..63da75dd4 100644 --- a/android/blockchain/src/androidTest/kotlin/com/gemwallet/android/blockchain/clients/aptos/TestAptosSigner.kt +++ b/android/blockchain/src/androidTest/kotlin/com/gemwallet/android/blockchain/clients/aptos/TestAptosSigner.kt @@ -1,5 +1,7 @@ package com.gemwallet.android.blockchain.clients.aptos +import uniffi.gemstone.GemTransactionLoadMetadata + import androidx.test.ext.junit.runners.AndroidJUnit4 import com.gemwallet.android.blockchain.includeLibs import com.gemwallet.android.blockchain.services.SignService @@ -46,9 +48,7 @@ class TestAptosSigner { BigInteger.valueOf(10_000_000_000), DestinationAddress("0x82111f2975a0f6080d178236369b7479f6aed1203ef4a23f8205e4b91716b783"), ), - chainData = AptosChainData( - 8UL, - ), + metadata = GemTransactionLoadMetadata.Aptos(sequence = 8UL, data = null), finalAmount = BigInteger.valueOf(10_000_000_000), Fee.Regular( feeAssetId = AssetId(Chain.Aptos), @@ -94,9 +94,7 @@ class TestAptosSigner { BigInteger.valueOf(10_000_000_000), DestinationAddress("0x82111f2975a0f6080d178236369b7479f6aed1203ef4a23f8205e4b91716b783"), ), - chainData = AptosChainData( - 8UL, - ), + metadata = GemTransactionLoadMetadata.Aptos(sequence = 8UL, data = null), fee = Fee.Regular( feeAssetId = AssetId(Chain.Aptos), priority = FeePriority.Normal, diff --git a/android/blockchain/src/androidTest/kotlin/com/gemwallet/android/blockchain/clients/bitcoin/TestBitcoinSigner.kt b/android/blockchain/src/androidTest/kotlin/com/gemwallet/android/blockchain/clients/bitcoin/TestBitcoinSigner.kt deleted file mode 100644 index 8f646baae..000000000 --- a/android/blockchain/src/androidTest/kotlin/com/gemwallet/android/blockchain/clients/bitcoin/TestBitcoinSigner.kt +++ /dev/null @@ -1,123 +0,0 @@ -package com.gemwallet.android.blockchain.clients.bitcoin - -import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.gemwallet.android.blockchain.includeLibs -import com.gemwallet.android.ext.asset -import com.gemwallet.android.math.append0x -import com.gemwallet.android.math.hex -import com.gemwallet.android.model.ConfirmParams -import com.gemwallet.android.model.DestinationAddress -import com.gemwallet.android.model.Fee -import com.gemwallet.android.testkit.TEST_PHRASE -import com.wallet.core.primitives.Account -import com.wallet.core.primitives.AssetId -import com.wallet.core.primitives.Chain -import com.wallet.core.primitives.FeePriority -import com.wallet.core.primitives.UTXO -import junit.framework.TestCase.assertEquals -import kotlinx.coroutines.runBlocking -import org.junit.Test -import org.junit.runner.RunWith -import wallet.core.jni.CoinType -import wallet.core.jni.HDWallet -import java.math.BigInteger - -@RunWith(AndroidJUnit4::class) -class TestBitcoinSigner { - - companion object { - const val SIGN_RESULT = "010000000153bd8a94cf6424d8e54cacbad6082c249cdacdd0956f766206a" + - "c3daed5e5479f010000006b483045022100ead2b7637532b167e66ddf76eafd032ada898c8719a" + - "680de8183b71fb3086a3e022055a073be9148cb8b9dc71de04755dbf11f140fee167ed0d4b44d6" + - "a54a829e75e012102fd6585adc0e86019abf00e83552d054cb5f4359ad4db8ca338099381f43e2" + - "5a5000000000182a82005000000001976a91424849c1d94eb9e6e002dd75fdcbce0a9673daba78" + - "8ac00000000" - init { - includeLibs() - } - } - - @Test - fun testBitcoinNativeSign() { - val privateKey = HDWallet(TEST_PHRASE, "").getKeyForCoin(CoinType.DOGECOIN) - val signer = BitcoinSignClient(Chain.Doge) - - val sign = runBlocking { - signer.signNativeTransfer( - params = ConfirmParams.TransferParams.Native( - Chain.Doge.asset(), - Account(Chain.Doge, "D8UBj4EfNfNWNCdnCSgpY48yZDqPdTZXWW", "", "dgub8rNuTi8ofZu1jVDKpBxW9VFo62kjjx3b6CcameEZnrNNHJ3sKCnWBxQSv6qAP6jrwZEpfT1ZdKsrcBFKGTMV8zgBtjZmvQt29VPnLzbHjjD"), - BigInteger.valueOf(10_000_000_000), - DestinationAddress("D8UBj4EfNfNWNCdnCSgpY48yZDqPdTZXWW"), - ), - chainData = BitcoinChainData( - listOf( - UTXO( - transaction_id = "9f47e5d5ae3dac0662766f95d0cdda9c242c08d6baac4ce5d82464cf948abd53", - vout = 1, - value = "86055170", - address = "" - ) - ), - ), - finalAmount = BigInteger.valueOf(10_000_000_000), - fee = Fee.Regular( - feeAssetId = AssetId(Chain.Doge), - priority = FeePriority.Normal, - amount = BigInteger.valueOf(2_700L), - maxGasPrice = BigInteger.valueOf(150L), - limit = BigInteger.valueOf(18L), - options = emptyMap(), - ), - privateKey.data() - ) - } - - assertEquals(SIGN_RESULT, String(sign.first())) - } - - @Test - fun testBitcoinSwapSign() { - val privateKey = HDWallet(TEST_PHRASE, "").getKeyForCoin(CoinType.DOGECOIN) - val signer = BitcoinSignClient(Chain.Doge) - - val params = ConfirmParams.TransferParams.Native( - Chain.Doge.asset(), - Account(Chain.Doge, "D8UBj4EfNfNWNCdnCSgpY48yZDqPdTZXWW", "", "dgub8rNuTi8ofZu1jVDKpBxW9VFo62kjjx3b6CcameEZnrNNHJ3sKCnWBxQSv6qAP6jrwZEpfT1ZdKsrcBFKGTMV8zgBtjZmvQt29VPnLzbHjjD"), - BigInteger.valueOf(10_000_000_000), - DestinationAddress("D8UBj4EfNfNWNCdnCSgpY48yZDqPdTZXWW"), - memo = "=:s:0xEe7E9CcFb529f2c1Cc02C0Aea8aCed7Ec7e98B5e:0/1/0:g1:50" - ) - val chainData = BitcoinChainData( - listOf( - UTXO( - transaction_id = "9f47e5d5ae3dac0662766f95d0cdda9c242c08d6baac4ce5d82464cf948abd53", - vout = 1, - value = "86055170", - address = "" - ) - ), - ) - val finalAmount = BigInteger.valueOf(10_000_000_000) - val fee = Fee.Regular( - feeAssetId = AssetId(Chain.Doge), - priority = FeePriority.Normal, - amount = BigInteger.valueOf(2_700L), - maxGasPrice = BigInteger.valueOf(150L), - limit = BigInteger.valueOf(18L), - options = emptyMap(), - ) - val input = signer.getSigningInput( - params = params, - chainData = chainData, - finalAmount = finalAmount, - fee = fee, - privateKey = privateKey.data(), - ) - - assertEquals( - "0x3d3a733a3078456537453943634662353239663263314363303243304165613861436564374563376539384235653a302f312f303a67313a3530", - input.outputOpReturn.toByteArray().hex.append0x() - ) - } -} diff --git a/android/blockchain/src/androidTest/kotlin/com/gemwallet/android/blockchain/clients/cardano/TestCardanoSigner.kt b/android/blockchain/src/androidTest/kotlin/com/gemwallet/android/blockchain/clients/cardano/TestCardanoSigner.kt index 3eb42dcf7..62477f47d 100644 --- a/android/blockchain/src/androidTest/kotlin/com/gemwallet/android/blockchain/clients/cardano/TestCardanoSigner.kt +++ b/android/blockchain/src/androidTest/kotlin/com/gemwallet/android/blockchain/clients/cardano/TestCardanoSigner.kt @@ -1,5 +1,8 @@ package com.gemwallet.android.blockchain.clients.cardano +import uniffi.gemstone.GemTransactionLoadMetadata +import com.gemwallet.android.domains.asset.toGem + import com.gemwallet.android.blockchain.includeLibs import com.gemwallet.android.blockchain.services.SignService import com.gemwallet.android.ext.asset @@ -39,7 +42,7 @@ class TestCardanoSigner { BigInteger.valueOf(10_000), DestinationAddress("addr1q9d2dxen8ywvs9yzxxn2w4mvffn797fquauvugt2ug7mfsuqj3lzdq9h0rsketzszrnfm930658swmpe7kpq53c2tmwql4rvtq"), ), - chainData = CardanoChainData( + metadata = GemTransactionLoadMetadata.Cardano( utxos = listOf( UTXO( address = "addr1q9d2dxen8ywvs9yzxxn2w4mvffn797fquauvugt2ug7mfsuqj3lzdq9h0rsketzszrnfm930658swmpe7kpq53c2tmwql4rvtq", @@ -47,7 +50,7 @@ class TestCardanoSigner { value = "7945975", vout = 1, ) - ), + ).toGem(), blockNumber = 189_992_800uL, ), finalAmount = BigInteger.valueOf(10_000), diff --git a/android/blockchain/src/androidTest/kotlin/com/gemwallet/android/blockchain/clients/near/TestNearSigner.kt b/android/blockchain/src/androidTest/kotlin/com/gemwallet/android/blockchain/clients/near/TestNearSigner.kt index c8a36c735..bc72c4018 100644 --- a/android/blockchain/src/androidTest/kotlin/com/gemwallet/android/blockchain/clients/near/TestNearSigner.kt +++ b/android/blockchain/src/androidTest/kotlin/com/gemwallet/android/blockchain/clients/near/TestNearSigner.kt @@ -1,5 +1,7 @@ package com.gemwallet.android.blockchain.clients.near +import uniffi.gemstone.GemTransactionLoadMetadata + import com.gemwallet.android.blockchain.includeLibs import com.gemwallet.android.blockchain.services.SignService import com.gemwallet.android.ext.asset @@ -42,10 +44,7 @@ class TestNearSigner { BigInteger.valueOf(10_000), DestinationAddress(from), ), - chainData = NearChainData( - block = "2ADR7pgpkd2uFFkQcAyCxL5YB4d9SewALTLEuFbUUJLe", - sequence = 134180900000002UL, - ), + metadata = GemTransactionLoadMetadata.Near(sequence = 134180900000002UL, blockHash = "2ADR7pgpkd2uFFkQcAyCxL5YB4d9SewALTLEuFbUUJLe"), finalAmount = BigInteger.valueOf(10_000), fee = Fee.Plain( priority = FeePriority.Normal, diff --git a/android/blockchain/src/androidTest/kotlin/com/gemwallet/android/blockchain/clients/solana/TestSolanaSigner.kt b/android/blockchain/src/androidTest/kotlin/com/gemwallet/android/blockchain/clients/solana/TestSolanaSigner.kt index 289412e4e..f7c15d7ec 100644 --- a/android/blockchain/src/androidTest/kotlin/com/gemwallet/android/blockchain/clients/solana/TestSolanaSigner.kt +++ b/android/blockchain/src/androidTest/kotlin/com/gemwallet/android/blockchain/clients/solana/TestSolanaSigner.kt @@ -1,5 +1,7 @@ package com.gemwallet.android.blockchain.clients.solana +import uniffi.gemstone.GemTransactionLoadMetadata + import com.gemwallet.android.blockchain.includeLibs import com.gemwallet.android.blockchain.services.SignService import com.gemwallet.android.ext.asset @@ -15,7 +17,6 @@ import com.wallet.core.primitives.AssetId import com.wallet.core.primitives.AssetType import com.wallet.core.primitives.Chain import com.wallet.core.primitives.FeePriority -import com.wallet.core.primitives.SolanaTokenProgramId import junit.framework.TestCase.assertEquals import kotlinx.coroutines.runBlocking import org.junit.Test @@ -57,16 +58,17 @@ class TestSolanaSigner { amount = BigInteger.valueOf(10_000_000) ) .transfer(destination = DestinationAddress("4Yu2e1Wz5T1Ci2hAPswDqvMgSnJ1Ftw7ZZh8x7xKLx7S")) as ConfirmParams.TransferParams.Native - val chainData = SolanaChainData( + val metadata = GemTransactionLoadMetadata.Solana( blockHash = "kiEPF6aKvEsj5nbi4FBvgRRm9ha36Y3cgDU9qnUKt32", recipientTokenAddress = null, senderTokenAddress = "", - tokenProgram = SolanaTokenProgramId.Token + tokenProgram = uniffi.gemstone.SolanaTokenProgramId.TOKEN, + nft = null ) val result = runBlocking { signer.signNativeTransfer( params, - chainData, + metadata, BigInteger.ZERO, Fee.Solana( amount = BigInteger("105005000"), @@ -109,16 +111,17 @@ class TestSolanaSigner { amount = BigInteger.valueOf(10_000_000) ) .transfer(destination = DestinationAddress("AGkXQZ9qm99xukisDUHvspWHESrcjs8Y4AmQQgef3BRh")) as ConfirmParams.TransferParams.Token - val chainData = SolanaChainData( + val metadata = GemTransactionLoadMetadata.Solana( blockHash = "kiEPF6aKvEsj5nbi4FBvgRRm9ha36Y3cgDU9qnUKt32", recipientTokenAddress = "DVWPV7brSbPDkA7a3qdn6UJsVc3J3DyhQhjNaZeZqwzo", senderTokenAddress = "DVWPV7brSbPDkA7a3qdn6UJsVc3J3DyhQhjNaZeZqwzo", - tokenProgram = SolanaTokenProgramId.Token + tokenProgram = uniffi.gemstone.SolanaTokenProgramId.TOKEN, + nft = null ) val result = runBlocking { signer.signTokenTransfer( params, - chainData, + metadata, BigInteger.ZERO, Fee.Solana( amount = BigInteger("105005000"), @@ -162,16 +165,17 @@ class TestSolanaSigner { amount = BigInteger.valueOf(10_000_000) ) .transfer(destination = DestinationAddress("AGkXQZ9qm99xukisDUHvspWHESrcjs8Y4AmQQgef3BRh")) as ConfirmParams.TransferParams.Token - val chainData = SolanaChainData( + val metadata = GemTransactionLoadMetadata.Solana( blockHash = "kiEPF6aKvEsj5nbi4FBvgRRm9ha36Y3cgDU9qnUKt32", recipientTokenAddress = "87vTugUvkkepa84mBRfENnvkPQRj5EZSkiG8XyFAhbQQ", senderTokenAddress = "87vTugUvkkepa84mBRfENnvkPQRj5EZSkiG8XyFAhbQQ", - tokenProgram = SolanaTokenProgramId.Token2022 + tokenProgram = uniffi.gemstone.SolanaTokenProgramId.TOKEN2022, + nft = null ) val result = runBlocking { signer.signTokenTransfer( params, - chainData, + metadata, BigInteger.ZERO, Fee.Solana( amount = BigInteger("105005000"), diff --git a/android/blockchain/src/androidTest/kotlin/com/gemwallet/android/blockchain/clients/stellar/TestStellarSigner.kt b/android/blockchain/src/androidTest/kotlin/com/gemwallet/android/blockchain/clients/stellar/TestStellarSigner.kt index 550198930..db9a65500 100644 --- a/android/blockchain/src/androidTest/kotlin/com/gemwallet/android/blockchain/clients/stellar/TestStellarSigner.kt +++ b/android/blockchain/src/androidTest/kotlin/com/gemwallet/android/blockchain/clients/stellar/TestStellarSigner.kt @@ -1,6 +1,7 @@ package com.gemwallet.android.blockchain.clients.Stellar -import com.gemwallet.android.blockchain.clients.stellar.StellarChainData +import uniffi.gemstone.GemTransactionLoadMetadata + import com.gemwallet.android.blockchain.includeLibs import com.gemwallet.android.blockchain.services.SignService import com.gemwallet.android.ext.asset @@ -43,7 +44,7 @@ class TestStellarSigner { BigInteger.valueOf(10_000), DestinationAddress(from), ), - chainData = StellarChainData( + metadata = GemTransactionLoadMetadata.Stellar( sequence = 1UL, isDestinationAddressExist = true, ), diff --git a/android/blockchain/src/androidTest/kotlin/com/gemwallet/android/blockchain/clients/sui/TestSuiSigner.kt b/android/blockchain/src/androidTest/kotlin/com/gemwallet/android/blockchain/clients/sui/TestSuiSigner.kt index 8ade6f30f..210a90497 100644 --- a/android/blockchain/src/androidTest/kotlin/com/gemwallet/android/blockchain/clients/sui/TestSuiSigner.kt +++ b/android/blockchain/src/androidTest/kotlin/com/gemwallet/android/blockchain/clients/sui/TestSuiSigner.kt @@ -1,5 +1,7 @@ package com.gemwallet.android.blockchain.clients.sui +import uniffi.gemstone.GemTransactionLoadMetadata + import com.gemwallet.android.blockchain.includeLibs import com.gemwallet.android.blockchain.services.SignService import com.gemwallet.android.ext.asset @@ -44,7 +46,7 @@ class TestSuiSigner { BigInteger.valueOf(10_000), DestinationAddress(from), ), - chainData = SuiChainData( + metadata = GemTransactionLoadMetadata.Sui( messageBytes = "AAACAAgAypo7AAAAAAAgLuHnHoVlKe7YHnpDy6mnmWueg/fpbWoPf2pUAO0b" + "wWgCAgABAQAAAQEDAAAAAAEBAC7h5x6FZSnu2B56Q8upp5lrnoP36W1qD39qVADtG8F" + "oAS2bHegcizOpgucdlh7PdMz4cCyV89Xv8+pSQHYdUVM07UIbGgAAAAAgbiAG3TMqRi" + @@ -91,7 +93,7 @@ class TestSuiSigner { BigInteger.valueOf(10_000), DestinationAddress(from), ), - chainData = SuiChainData( + metadata = GemTransactionLoadMetadata.Sui( messageBytes = "AAAEAQA+cu/kqxz/pp3Qmo6eoLJz+so76TaSloB1SmEUVhWCaUYoGhwAAAAAI" + "BtZ+Y3WbB+PQtHrS7YgMDZGLzVxrT20trS/6hpbAEmBAQC01EdV46fEpnMdgod7BT2jJ" + "F0uO3bB4vxKQwUM5D5LgUYoGhwAAAAAIG7IzS1+nt9AlH/Ky7M7uvu/hnOkXUjbo13FT" + diff --git a/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/clients/SignClient.kt b/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/clients/SignClient.kt index 980c76f5a..d99c2f0bd 100644 --- a/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/clients/SignClient.kt +++ b/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/clients/SignClient.kt @@ -1,12 +1,12 @@ package com.gemwallet.android.blockchain.clients -import com.gemwallet.android.model.ChainSignData +import uniffi.gemstone.GemTransactionLoadMetadata import com.gemwallet.android.model.ConfirmParams import com.gemwallet.android.model.Fee import com.wallet.core.primitives.Chain import java.math.BigInteger -interface SignClient : BlockchainClient { +interface SignClient { suspend fun signMessage( chain: Chain, @@ -18,7 +18,7 @@ interface SignClient : BlockchainClient { suspend fun signGenericTransfer( params: ConfirmParams.TransferParams.Generic, - chainData: ChainSignData, + metadata: GemTransactionLoadMetadata, finalAmount: BigInteger, fee: Fee, privateKey: ByteArray, @@ -26,7 +26,7 @@ interface SignClient : BlockchainClient { suspend fun signNativeTransfer( params: ConfirmParams.TransferParams.Native, - chainData: ChainSignData, + metadata: GemTransactionLoadMetadata, finalAmount: BigInteger, fee: Fee, privateKey: ByteArray, @@ -34,7 +34,7 @@ interface SignClient : BlockchainClient { suspend fun signTokenTransfer( params: ConfirmParams.TransferParams.Token, - chainData: ChainSignData, + metadata: GemTransactionLoadMetadata, finalAmount: BigInteger, fee: Fee, privateKey: ByteArray, @@ -42,7 +42,7 @@ interface SignClient : BlockchainClient { suspend fun signSwap( params: ConfirmParams.SwapParams, - chainData: ChainSignData, + metadata: GemTransactionLoadMetadata, finalAmount: BigInteger, fee: Fee, privateKey: ByteArray, @@ -50,7 +50,7 @@ interface SignClient : BlockchainClient { suspend fun signTokenApproval( params: ConfirmParams.TokenApprovalParams, - chainData: ChainSignData, + metadata: GemTransactionLoadMetadata, finalAmount: BigInteger, fee: Fee, privateKey: ByteArray, @@ -58,7 +58,7 @@ interface SignClient : BlockchainClient { suspend fun signDelegate( params: ConfirmParams.Stake.DelegateParams, - chainData: ChainSignData, + metadata: GemTransactionLoadMetadata, finalAmount: BigInteger, fee: Fee, privateKey: ByteArray, @@ -66,7 +66,7 @@ interface SignClient : BlockchainClient { suspend fun signUndelegate( params: ConfirmParams.Stake.UndelegateParams, - chainData: ChainSignData, + metadata: GemTransactionLoadMetadata, finalAmount: BigInteger, fee: Fee, privateKey: ByteArray, @@ -74,7 +74,7 @@ interface SignClient : BlockchainClient { suspend fun signRedelegate( params: ConfirmParams.Stake.RedelegateParams, - chainData: ChainSignData, + metadata: GemTransactionLoadMetadata, finalAmount: BigInteger, fee: Fee, privateKey: ByteArray, @@ -82,7 +82,7 @@ interface SignClient : BlockchainClient { suspend fun signRewards( params: ConfirmParams.Stake.RewardsParams, - chainData: ChainSignData, + metadata: GemTransactionLoadMetadata, finalAmount: BigInteger, fee: Fee, privateKey: ByteArray, @@ -90,7 +90,7 @@ interface SignClient : BlockchainClient { suspend fun signWithdraw( params: ConfirmParams.Stake.WithdrawParams, - chainData: ChainSignData, + metadata: GemTransactionLoadMetadata, finalAmount: BigInteger, fee: Fee, privateKey: ByteArray, @@ -98,7 +98,7 @@ interface SignClient : BlockchainClient { suspend fun signActivate( params: ConfirmParams.Activate, - chainData: ChainSignData, + metadata: GemTransactionLoadMetadata, finalAmount: BigInteger, fee: Fee, privateKey: ByteArray, @@ -106,7 +106,7 @@ interface SignClient : BlockchainClient { suspend fun signNft( params: ConfirmParams.NftParams, - chainData: ChainSignData, + metadata: GemTransactionLoadMetadata, finalAmount: BigInteger, fee: Fee, privateKey: ByteArray, @@ -114,7 +114,7 @@ interface SignClient : BlockchainClient { suspend fun signFreeze( params: ConfirmParams.Stake.Freeze, - chainData: ChainSignData, + metadata: GemTransactionLoadMetadata, finalAmount: BigInteger, fee: Fee, privateKey: ByteArray, @@ -122,7 +122,7 @@ interface SignClient : BlockchainClient { suspend fun signUnfreeze( params: ConfirmParams.Stake.Unfreeze, - chainData: ChainSignData, + metadata: GemTransactionLoadMetadata, finalAmount: BigInteger, fee: Fee, privateKey: ByteArray, @@ -130,7 +130,7 @@ interface SignClient : BlockchainClient { suspend fun signPerpetual( params: ConfirmParams.PerpetualParams, - chainData: ChainSignData, + metadata: GemTransactionLoadMetadata, finalAmount: BigInteger, fee: Fee, privateKey: ByteArray, diff --git a/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/clients/algorand/AlgorandChainData.kt b/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/clients/algorand/AlgorandChainData.kt deleted file mode 100644 index 48fa856ad..000000000 --- a/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/clients/algorand/AlgorandChainData.kt +++ /dev/null @@ -1,26 +0,0 @@ -package com.gemwallet.android.blockchain.clients.algorand - -import com.gemwallet.android.model.ChainSignData -import uniffi.gemstone.GemTransactionLoadMetadata - -data class AlgorandChainData( - val sequence: ULong, - val block: String, - val chainId: String, -) : ChainSignData { - override fun toDto(): GemTransactionLoadMetadata { - return GemTransactionLoadMetadata.Algorand( - sequence = sequence, - blockHash = block, - chainId = chainId, - ) - } -} - -fun GemTransactionLoadMetadata.Algorand.toChainData(): AlgorandChainData { - return AlgorandChainData( - sequence = sequence, - block = blockHash, - chainId = chainId, - ) -} \ No newline at end of file diff --git a/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/clients/aptos/AptosChainData.kt b/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/clients/aptos/AptosChainData.kt deleted file mode 100644 index 7f1232f46..000000000 --- a/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/clients/aptos/AptosChainData.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.gemwallet.android.blockchain.clients.aptos - -import com.gemwallet.android.model.ChainSignData -import uniffi.gemstone.GemTransactionLoadMetadata - -data class AptosChainData( - val sequence: ULong, - val data: String? = null, -) : ChainSignData { - override fun toDto(): GemTransactionLoadMetadata = GemTransactionLoadMetadata.Aptos( - sequence = sequence, - data = data, - ) -} - -fun GemTransactionLoadMetadata.Aptos.toChainData(): AptosChainData { - return AptosChainData( - sequence = sequence, - data = data, - ) -} diff --git a/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/clients/bitcoin/BitcoinChainData.kt b/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/clients/bitcoin/BitcoinChainData.kt deleted file mode 100644 index 26a0d3778..000000000 --- a/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/clients/bitcoin/BitcoinChainData.kt +++ /dev/null @@ -1,41 +0,0 @@ -package com.gemwallet.android.blockchain.clients.bitcoin - -import com.gemwallet.android.domains.asset.toGem -import com.gemwallet.android.domains.asset.toUtxo -import com.gemwallet.android.model.ChainSignData -import com.wallet.core.primitives.UTXO -import uniffi.gemstone.GemTransactionLoadMetadata - -data class BitcoinChainData( - val utxo: List, -) : ChainSignData { - override fun toDto(): GemTransactionLoadMetadata { - return GemTransactionLoadMetadata.Bitcoin( - utxo.toGem() - ) - } -} - -data class ZCashChainData( - val utxo: List, - val branchId: String, -) : ChainSignData { - override fun toDto(): GemTransactionLoadMetadata { - return GemTransactionLoadMetadata.Bitcoin( - utxo.toGem() - ) - } -} - -fun GemTransactionLoadMetadata.Bitcoin.toChainData(): BitcoinChainData { - return BitcoinChainData( - utxo = utxos.toUtxo(), - ) -} - -fun GemTransactionLoadMetadata.Zcash.toChainData(): ZCashChainData { - return ZCashChainData( - utxo = utxos.toUtxo(), - branchId = branchId, - ) -} diff --git a/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/clients/bitcoin/BitcoinGatewayEstimateFee.kt b/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/clients/bitcoin/BitcoinGatewayEstimateFee.kt deleted file mode 100644 index 94eb5db74..000000000 --- a/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/clients/bitcoin/BitcoinGatewayEstimateFee.kt +++ /dev/null @@ -1,111 +0,0 @@ -package com.gemwallet.android.blockchain.clients.bitcoin - -import com.gemwallet.android.blockchain.operators.walletcore.WCChainTypeProxy -import com.gemwallet.android.domains.asset.toUtxo -import com.gemwallet.android.ext.toChain -import com.gemwallet.android.model.GemPlatformErrors -import com.wallet.core.primitives.UTXO -import uniffi.gemstone.Chain -import uniffi.gemstone.GatewayException -import uniffi.gemstone.GemFeeOptions -import uniffi.gemstone.GemGasPriceType -import uniffi.gemstone.GemGatewayEstimateFee -import uniffi.gemstone.GemTransactionLoadFee -import uniffi.gemstone.GemTransactionLoadInput -import uniffi.gemstone.GemTransactionLoadMetadata -import wallet.core.java.AnySigner -import wallet.core.jni.BitcoinSigHashType -import wallet.core.jni.proto.Bitcoin -import wallet.core.jni.proto.Common -import java.math.BigInteger - -class BitcoinGatewayEstimateFee : GemGatewayEstimateFee { - override suspend fun getFee( - chain: Chain, - input: GemTransactionLoadInput - ): GemTransactionLoadFee? { - val chain = chain.toChain() ?: throw IllegalArgumentException("Incorrect chain") - - val utxos = input.metadata.let { metadata -> - when (metadata) { - is GemTransactionLoadMetadata.Zcash -> metadata.utxos.toUtxo() - is GemTransactionLoadMetadata.Bitcoin -> metadata.utxos.toUtxo() - else -> throw IllegalArgumentException("Incorrect UTXO") - } - } - - val bytePrice = (input.gasPrice as? GemGasPriceType.Regular)?.gasPrice?.toLongOrNull() - ?: throw IllegalArgumentException("Incorrect Byte Price") - val amount = input.value.toLongOrNull() ?: return null - - val destinationAddress = input.destinationAddress - val senderAddress = input.senderAddress - - val fee = calcFee( - chain = chain, - senderAddress = senderAddress, - destinationAddress = destinationAddress, - amount = amount, - bytePrice = bytePrice, - utxos = utxos, - isMaxValue = input.isMaxValue, - ) - - return GemTransactionLoadFee( - fee = fee.toString(), - gasPriceType = input.gasPrice, - gasLimit = "1", - options = GemFeeOptions(emptyMap()) - ) - } - - override suspend fun getFeeData( - chain: Chain, - input: GemTransactionLoadInput - ): String? { - return null - } - - private fun calcFee( - chain: com.wallet.core.primitives.Chain, - senderAddress: String, - destinationAddress: String, - amount: Long, - bytePrice: Long, - utxos: List, - isMaxValue: Boolean, - ): BigInteger { - val coinType = WCChainTypeProxy().invoke(chain) - val total = utxos.map { it.value.toLong() }.fold(0L) { x, y -> x + y } - if (total == 0L) { - return BigInteger.ZERO // empty balance - } - val input = Bitcoin.SigningInput.newBuilder().apply { - this.hashType = BitcoinSigHashType.ALL.value() - this.byteFee = bytePrice - this.amount = amount - this.useMaxAmount = isMaxValue - this.coinType = coinType.value() - this.toAddress = destinationAddress - this.changeAddress = senderAddress - this.addAllUtxo(utxos.getUtxoTransactions(senderAddress, coinType)) - }.build() - - val plan = AnySigner.plan(input, coinType, Bitcoin.TransactionPlan.parser()) - when (plan.error) { - Common.SigningError.OK -> { /* continue */ } - Common.SigningError.Error_not_enough_utxos, - Common.SigningError.Error_dust_amount_requested, - Common.SigningError.Error_missing_input_utxos -> throw GatewayException.PlatformException(GemPlatformErrors.Dust.message) - else -> throw GatewayException.PlatformException(plan.error.name) - } - - val hasSelectedUtxos = plan.utxosList.any { raw -> - input.utxoList?.any { it == raw } == true - } - if (utxos.isNotEmpty() && !hasSelectedUtxos && amount != total && amount <= total) { - return calcFee(chain, senderAddress, destinationAddress, total, bytePrice, utxos, isMaxValue) - } - return BigInteger.valueOf(plan.fee) - } -} diff --git a/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/clients/bitcoin/BitcoinSignClient.kt b/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/clients/bitcoin/BitcoinSignClient.kt deleted file mode 100644 index e5c2d577e..000000000 --- a/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/clients/bitcoin/BitcoinSignClient.kt +++ /dev/null @@ -1,224 +0,0 @@ -package com.gemwallet.android.blockchain.clients.bitcoin - -import com.gemwallet.android.blockchain.clients.SignClient -import com.gemwallet.android.blockchain.operators.walletcore.WCChainTypeProxy -import com.gemwallet.android.math.append0x -import com.gemwallet.android.math.hex -import com.gemwallet.android.math.fromHex -import com.gemwallet.android.model.ChainSignData -import com.gemwallet.android.model.ConfirmParams -import com.gemwallet.android.model.Fee -import com.google.protobuf.ByteString -import com.wallet.core.primitives.Chain -import com.wallet.core.primitives.SwapProvider -import com.wallet.core.primitives.UTXO -import wallet.core.java.AnySigner -import wallet.core.jni.BitcoinScript -import wallet.core.jni.CoinType -import wallet.core.jni.proto.Bitcoin -import wallet.core.jni.proto.Common -import java.math.BigInteger -import kotlin.math.max - -class BitcoinSignClient( - private val chain: Chain, -) : SignClient { - - val coinType = WCChainTypeProxy().invoke(chain) - - override suspend fun signNativeTransfer( - params: ConfirmParams.TransferParams.Native, - chainData: ChainSignData, - finalAmount: BigInteger, - fee: Fee, - privateKey: ByteArray - ): List { - val signingInput = getSigningInput( - params, - chainData, - finalAmount, - fee, - privateKey - ) - return sign(signingInput.build()) - } - - override suspend fun signSwap( - params: ConfirmParams.SwapParams, - chainData: ChainSignData, - finalAmount: BigInteger, - fee: Fee, - privateKey: ByteArray - ): List { - val providers = setOf(SwapProvider.Thorchain, SwapProvider.Chainflip).map { it.string } - if (!providers.contains(params.protocolId)) { - throw Exception("Invalid signing input type or not supported provider id") - } - if (params.useMaxAmount && params.protocolId == SwapProvider.Chainflip.string) { - throw Exception("Invalid signing input type or not supported provider id") - } - val signingInput = getSigningInput( - params, - chainData, - finalAmount, - fee, - privateKey, - ) - when(params.protocolId) { - SwapProvider.Chainflip.string -> { - signingInput.outputOpReturnIndex = Bitcoin.OutputIndex.newBuilder().apply { index = 1 }.build() - signingInput.outputOpReturn = try { - ByteString.copyFrom(params.swapData.fromHex()) - } catch (_: Throwable) { - throw Exception("Invalid Chainflip swap data") - } - } - else -> { - signingInput.outputOpReturn = ByteString.copyFrom(params.swapData.toByteArray()) - } - } - - return sign(signingInput.build()) - } - - fun getSigningInput( - params: ConfirmParams, - chainData: ChainSignData, - finalAmount: BigInteger, - fee: Fee, - privateKey: ByteArray - ): Bitcoin.SigningInput.Builder { - return when (chain) { - Chain.Zcash -> getSigningInputZcash(params, chainData, finalAmount, fee, privateKey) - else -> getSigningInputBitcoin(params, chainData, finalAmount, fee, privateKey) - }.apply { - params.memo()?.let { memo -> - outputOpReturn = ByteString.copyFrom(memo.toByteArray()) - } - } - } - - fun getSigningInputBitcoin( - params: ConfirmParams, - chainData: ChainSignData, - finalAmount: BigInteger, - fee: Fee, - privateKey: ByteArray - ): Bitcoin.SigningInput.Builder { - val chainData = chainData as BitcoinChainData - val gasFee = fee as Fee.Regular - val coinType = coinType - - return Bitcoin.SigningInput.newBuilder().apply { - this.coinType = coinType.value() - this.hashType = BitcoinScript.hashTypeForCoin(coinType) - this.amount = finalAmount.toLong() - this.byteFee = gasFee.maxGasPrice.toLong() - this.toAddress = params.destination()?.address - this.changeAddress = params.from.address - this.useMaxAmount = params.useMaxAmount - - this.addPrivateKey(ByteString.copyFrom(privateKey)) - this.addAllUtxo(chainData.utxo.getUtxoTransactions(params.from.address, coinType)) - chainData.utxo.forEach { _ -> - val redeemScript = BitcoinScript.lockScriptForAddress(params.from.address, coinType) - val scriptData = redeemScript.data() - if (coinType == CoinType.BITCOIN || scriptData?.isEmpty() != false) { - return@forEach - } - val keyHash = if (redeemScript.isPayToWitnessPublicKeyHash) { - redeemScript.matchPayToWitnessPublicKeyHash() - } else { - redeemScript.matchPayToPubkeyHash() - }.hex.append0x() - putScripts(keyHash, ByteString.copyFrom(redeemScript.data())) - } - } - } - - private fun getSigningInputZcash( - params: ConfirmParams, - chainData: ChainSignData, - finalAmount: BigInteger, - fee: Fee, - privateKey: ByteArray - ): Bitcoin.SigningInput.Builder { - val chainData = chainData as ZCashChainData - val coinType = coinType - val signingInput = Bitcoin.SigningInput.newBuilder().apply { - this.coinType = coinType.value() - this.hashType = BitcoinScript.hashTypeForCoin(coinType) - this.amount = finalAmount.toLong() - this.byteFee = 0 - this.toAddress = params.destination()?.address - this.changeAddress = params.from.address - this.addAllUtxo(chainData.utxo.getUtxoTransactions(params.from.address, coinType)) - this.useMaxAmount = params.useMaxAmount - this.zip0317 = false - - chainData.utxo.forEach { _ -> - val redeemScript = BitcoinScript.lockScriptForAddress(params.from.address, coinType) - val scriptData = redeemScript.data() - if (scriptData.isEmpty()) { - return@forEach - } - val keyHash = if (redeemScript.isPayToWitnessPublicKeyHash) { - redeemScript.matchPayToWitnessPublicKeyHash() - } else { - redeemScript.matchPayToPubkeyHash() - }.hex.append0x() - putScripts(keyHash, ByteString.copyFrom(redeemScript.data())) - } - this.addPrivateKey(ByteString.copyFrom(privateKey)) - } - val totalAvailable = chainData.utxo.fold(BigInteger.ZERO) { acc, uTXO -> acc + (uTXO.value.toBigIntegerOrNull() ?: BigInteger.ZERO) }.toLong() - val fee = (fee as Fee.Regular).amount.toLong() - val requestAmount = finalAmount.toLong() - val targetAmount = if (params.useMaxAmount) max(totalAvailable - fee, 0) else requestAmount - if ((totalAvailable - fee) < targetAmount) { - throw IllegalStateException() - } - val change = max(totalAvailable - targetAmount - fee, 0) - signingInput.amount = targetAmount - signingInput.plan = Bitcoin.TransactionPlan.newBuilder().apply { - this.amount = targetAmount - this.availableAmount = totalAvailable - this.fee = fee - this.change = change - this.addAllUtxos(chainData.utxo.getUtxoTransactions(params.from.address, coinType)) - this.branchId = ByteString.copyFrom(chainData.branchId.fromHex().apply { reverse() }) - }.build() - - return signingInput - } - - private fun sign(signingInput: Bitcoin.SigningInput): List { - val output = AnySigner.sign(signingInput, coinType, Bitcoin.SigningOutput.parser()) - if (output.error != Common.SigningError.OK) { - throw IllegalStateException(output.error.name) - } - if (output.errorMessage.isNotEmpty()) { - throw IllegalStateException(output.errorMessage) - } - return listOf(output.encoded.toByteArray().hex.toByteArray()) - } - - override fun supported(chain: Chain): Boolean = this.chain == chain -} - -fun List.getUtxoTransactions(address: String, coinType: CoinType): List { - return map { utxo -> - Bitcoin.UnspentTransaction.newBuilder().apply { - val hash = utxo.transaction_id.fromHex() - hash.reverse() - this.outPoint = Bitcoin.OutPoint.newBuilder().apply { - this.hash = ByteString.copyFrom(hash) - this.index = utxo.vout - }.build() - this.amount = utxo.value.toLong() - this.script = ByteString.copyFrom( - BitcoinScript.lockScriptForAddress(address, coinType).data() - ) - }.build() - } -} diff --git a/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/clients/cardano/CardanoChainData.kt b/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/clients/cardano/CardanoChainData.kt deleted file mode 100644 index f21a23766..000000000 --- a/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/clients/cardano/CardanoChainData.kt +++ /dev/null @@ -1,28 +0,0 @@ -package com.gemwallet.android.blockchain.clients.cardano - -import com.gemwallet.android.domains.asset.toGem -import com.gemwallet.android.domains.asset.toUtxo -import com.gemwallet.android.model.ChainSignData -import com.wallet.core.primitives.UTXO -import uniffi.gemstone.GemTransactionLoadMetadata - -data class CardanoChainData( - val utxos: List, - val blockNumber: ULong, -) : ChainSignData { - override fun blockNumber(): String = blockNumber.toString() - - override fun toDto(): GemTransactionLoadMetadata { - return GemTransactionLoadMetadata.Cardano( - utxos.toGem(), - blockNumber, - ) - } -} - -fun GemTransactionLoadMetadata.Cardano.toChainData(): CardanoChainData { - return CardanoChainData( - utxos = utxos.toUtxo(), - blockNumber = blockNumber, - ) -} diff --git a/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/clients/cosmos/CosmosChainData.kt b/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/clients/cosmos/CosmosChainData.kt deleted file mode 100644 index ec5191e87..000000000 --- a/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/clients/cosmos/CosmosChainData.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.gemwallet.android.blockchain.clients.cosmos - -import com.gemwallet.android.model.ChainSignData -import uniffi.gemstone.GemTransactionLoadMetadata - -data class CosmosChainData( - val chainId: String, - val accountNumber: ULong, - val sequence: ULong, -) : ChainSignData { - override fun toDto(): GemTransactionLoadMetadata = GemTransactionLoadMetadata.Cosmos( - accountNumber = accountNumber, - chainId = chainId, - sequence = sequence, - ) -} - -fun GemTransactionLoadMetadata.Cosmos.toChainData(): CosmosChainData { - return CosmosChainData( - chainId = chainId, - accountNumber = accountNumber, - sequence = sequence - ) -} \ No newline at end of file diff --git a/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/clients/ethereum/EvmChainData.kt b/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/clients/ethereum/EvmChainData.kt deleted file mode 100644 index d36c325bc..000000000 --- a/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/clients/ethereum/EvmChainData.kt +++ /dev/null @@ -1,60 +0,0 @@ -package com.gemwallet.android.blockchain.clients.ethereum - -import com.gemwallet.android.model.ChainSignData -import com.wallet.core.primitives.ContractCallData -import com.wallet.core.primitives.swap.ApprovalData -import uniffi.gemstone.GemApprovalData -import uniffi.gemstone.GemContractCallData -import uniffi.gemstone.GemTransactionLoadMetadata -import java.math.BigInteger - -data class EvmChainData( - val chainId: Int, - val nonce: BigInteger, - val contractCall: ContractCallData?, -) : ChainSignData { - override fun toDto(): GemTransactionLoadMetadata { - return GemTransactionLoadMetadata.Evm( - chainId = chainId.toLong().toULong(), - nonce = nonce.toLong().toULong(), - contractCall = contractCall?.let { - GemContractCallData( - contractAddress = it.contractAddress, - callData = it.callData, - approval = it.approval?.let { - GemApprovalData( - token = it.token, - spender = it.spender, - value = it.value, - isUnlimited = it.isUnlimited, - ) - }, - gasLimit = it.gasLimit, - ) - } - ) - } -} - - -fun GemTransactionLoadMetadata.Evm.toChainData(): EvmChainData { - return EvmChainData( - chainId = chainId.toInt(), - nonce = BigInteger.valueOf(nonce.toLong()), - contractCall = contractCall?.let { - ContractCallData( - contractAddress = it.contractAddress, - callData = it.callData, - approval = it.approval?.let { - ApprovalData( - token = it.token, - spender = it.spender, - value = it.value, - isUnlimited = it.isUnlimited, - ) - }, - gasLimit = it.gasLimit, - ) - } - ) -} diff --git a/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/clients/hyper/HyperCoreChainData.kt b/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/clients/hyper/HyperCoreChainData.kt deleted file mode 100644 index fe5eff2ec..000000000 --- a/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/clients/hyper/HyperCoreChainData.kt +++ /dev/null @@ -1,59 +0,0 @@ -package com.gemwallet.android.blockchain.clients.hyper - -import com.gemwallet.android.model.ChainSignData -import uniffi.gemstone.GemHyperliquidOrder -import uniffi.gemstone.GemTransactionLoadMetadata - -class HyperCoreChainData( - val order: Order? -) : ChainSignData { - override fun toDto(): GemTransactionLoadMetadata { - val order = order?.let { - GemHyperliquidOrder( - approveAgentRequired = it.approveAgentRequired, - approveReferralRequired = it.approveReferralRequired, - approveBuilderRequired = it.approveBuilderRequired, - builderFeeBps = it.builderFeeBps, - agentAddress = it.agentAddress, - agentPrivateKey = it.agentPrivateKey, - ) - } - return GemTransactionLoadMetadata.Hyperliquid(order) - } - - class Order( - val approveAgentRequired: Boolean, - val approveReferralRequired: Boolean, - val approveBuilderRequired: Boolean, - val builderFeeBps: UInt, - val agentAddress: String, - val agentPrivateKey: String - ) -} - -fun GemTransactionLoadMetadata.Hyperliquid.toChainData(): HyperCoreChainData { - val order = order?.let { - HyperCoreChainData.Order( - approveAgentRequired = it.approveAgentRequired, - approveReferralRequired = it.approveReferralRequired, - approveBuilderRequired = it.approveBuilderRequired, - builderFeeBps = it.builderFeeBps, - agentAddress = it.agentAddress, - agentPrivateKey = it.agentPrivateKey, - ) - } - return HyperCoreChainData(order) -} - -fun HyperCoreChainData.toGem() = GemTransactionLoadMetadata.Hyperliquid( - order = order?.let { - GemHyperliquidOrder( - approveAgentRequired = it.approveAgentRequired, - approveReferralRequired = it.approveReferralRequired, - approveBuilderRequired = it.approveBuilderRequired, - builderFeeBps = it.builderFeeBps, - agentAddress = it.agentAddress, - agentPrivateKey = it.agentPrivateKey, - ) - } -) \ No newline at end of file diff --git a/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/clients/near/NearChainData.kt b/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/clients/near/NearChainData.kt deleted file mode 100644 index e0ff2fdae..000000000 --- a/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/clients/near/NearChainData.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.gemwallet.android.blockchain.clients.near - -import com.gemwallet.android.model.ChainSignData -import uniffi.gemstone.GemTransactionLoadMetadata - -data class NearChainData( - val block: String, - val sequence: ULong, -) : ChainSignData { - override fun toDto(): GemTransactionLoadMetadata { - return GemTransactionLoadMetadata.Near( - blockHash = block, - sequence = sequence, - ) - } -} - -fun GemTransactionLoadMetadata.Near.toChainData(): NearChainData { - return NearChainData( - block = blockHash, - sequence = sequence - ) -} \ No newline at end of file diff --git a/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/clients/polkadot/PolkadotChainData.kt b/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/clients/polkadot/PolkadotChainData.kt deleted file mode 100644 index 8fef06ab7..000000000 --- a/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/clients/polkadot/PolkadotChainData.kt +++ /dev/null @@ -1,40 +0,0 @@ -package com.gemwallet.android.blockchain.clients.polkadot - -import com.gemwallet.android.model.ChainSignData -import uniffi.gemstone.GemTransactionLoadMetadata - -data class PolkadotChainData( - val sequence: ULong, - val genesisHash: String, - val blockHash: String, - val blockNumber: ULong, - val specVersion: ULong, - val transactionVersion: ULong, - val period: Long, -) : ChainSignData { - override fun blockNumber(): String = blockNumber.toString() - - override fun toDto(): GemTransactionLoadMetadata { - return GemTransactionLoadMetadata.Polkadot( - sequence = sequence, - genesisHash = genesisHash, - blockHash = blockHash, - blockNumber = blockNumber, - specVersion = specVersion, - transactionVersion = transactionVersion, - period = period.toULong(), - ) - } -} - -fun GemTransactionLoadMetadata.Polkadot.toChainData(): PolkadotChainData { - return PolkadotChainData( - sequence = sequence, - genesisHash = genesisHash, - blockHash = blockHash, - blockNumber = blockNumber, - specVersion = specVersion, - transactionVersion = transactionVersion, - period = period.toLong(), - ) -} \ No newline at end of file diff --git a/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/clients/solana/SolanaChainData.kt b/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/clients/solana/SolanaChainData.kt deleted file mode 100644 index 6d7158a3d..000000000 --- a/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/clients/solana/SolanaChainData.kt +++ /dev/null @@ -1,62 +0,0 @@ -package com.gemwallet.android.blockchain.clients.solana - -import com.gemwallet.android.model.ChainSignData -import com.wallet.core.primitives.SolanaNftStandard -import com.wallet.core.primitives.SolanaNftStandardCoreInner -import com.wallet.core.primitives.SolanaNftStandardProgrammableNonFungibleInner -import com.wallet.core.primitives.SolanaTokenProgramId -import uniffi.gemstone.GemTransactionLoadMetadata - -data class SolanaChainData( - val blockHash: String, - val senderTokenAddress: String?, - val recipientTokenAddress: String?, - val tokenProgram: SolanaTokenProgramId?, - val nft: SolanaNftStandard? = null, -) : ChainSignData { - override fun toDto(): GemTransactionLoadMetadata { - return GemTransactionLoadMetadata.Solana( - blockHash = blockHash, - senderTokenAddress = senderTokenAddress, - recipientTokenAddress = recipientTokenAddress, - tokenProgram = tokenProgram?.toUniffi(), - nft = nft?.toUniffi(), - ) - } -} - -fun GemTransactionLoadMetadata.Solana.toChainData(): SolanaChainData { - return SolanaChainData( - blockHash = blockHash, - senderTokenAddress = senderTokenAddress, - recipientTokenAddress = recipientTokenAddress, - tokenProgram = tokenProgram?.toPrimitives(), - nft = nft?.toPrimitives(), - ) -} - -private fun SolanaTokenProgramId.toUniffi(): uniffi.gemstone.SolanaTokenProgramId = when (this) { - SolanaTokenProgramId.Token -> uniffi.gemstone.SolanaTokenProgramId.TOKEN - SolanaTokenProgramId.Token2022 -> uniffi.gemstone.SolanaTokenProgramId.TOKEN2022 -} - -private fun uniffi.gemstone.SolanaTokenProgramId.toPrimitives(): SolanaTokenProgramId = when (this) { - uniffi.gemstone.SolanaTokenProgramId.TOKEN -> SolanaTokenProgramId.Token - uniffi.gemstone.SolanaTokenProgramId.TOKEN2022 -> SolanaTokenProgramId.Token2022 -} - -private fun SolanaNftStandard.toUniffi(): uniffi.gemstone.SolanaNftStandard = when (this) { - is SolanaNftStandard.NonFungible -> uniffi.gemstone.SolanaNftStandard.NonFungible - is SolanaNftStandard.ProgrammableNonFungible -> uniffi.gemstone.SolanaNftStandard.ProgrammableNonFungible(data.rule_set) - is SolanaNftStandard.Core -> uniffi.gemstone.SolanaNftStandard.Core(data.collection) -} - -private fun uniffi.gemstone.SolanaNftStandard.toPrimitives(): SolanaNftStandard = when (this) { - is uniffi.gemstone.SolanaNftStandard.NonFungible -> SolanaNftStandard.NonFungible - is uniffi.gemstone.SolanaNftStandard.ProgrammableNonFungible -> SolanaNftStandard.ProgrammableNonFungible( - SolanaNftStandardProgrammableNonFungibleInner(rule_set = ruleSet) - ) - is uniffi.gemstone.SolanaNftStandard.Core -> SolanaNftStandard.Core( - SolanaNftStandardCoreInner(collection = collection) - ) -} diff --git a/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/clients/stellar/StellarChainData.kt b/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/clients/stellar/StellarChainData.kt deleted file mode 100644 index 9e8523597..000000000 --- a/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/clients/stellar/StellarChainData.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.gemwallet.android.blockchain.clients.stellar - -import com.gemwallet.android.model.ChainSignData -import uniffi.gemstone.GemTransactionLoadMetadata - -data class StellarChainData( - val sequence: ULong, - val isDestinationAddressExist: Boolean, -) : ChainSignData { - override fun toDto(): GemTransactionLoadMetadata { - return GemTransactionLoadMetadata.Stellar( - sequence = sequence, - isDestinationAddressExist = isDestinationAddressExist, - ) - } -} - -fun GemTransactionLoadMetadata.Stellar.toChainData(): StellarChainData { - return StellarChainData( - sequence = sequence, - isDestinationAddressExist = isDestinationAddressExist, - ) -} \ No newline at end of file diff --git a/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/clients/sui/SuiChainData.kt b/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/clients/sui/SuiChainData.kt deleted file mode 100644 index a944cf77e..000000000 --- a/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/clients/sui/SuiChainData.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.gemwallet.android.blockchain.clients.sui - -import com.gemwallet.android.model.ChainSignData -import uniffi.gemstone.GemTransactionLoadMetadata - -data class SuiChainData( - val messageBytes: String, -) : ChainSignData { - override fun toDto(): GemTransactionLoadMetadata { - return GemTransactionLoadMetadata.Sui( - messageBytes = messageBytes - ) - } -} - -fun GemTransactionLoadMetadata.Sui.toChainData(): SuiChainData { - return SuiChainData( - messageBytes = messageBytes, - ) -} \ No newline at end of file diff --git a/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/clients/ton/TonChainData.kt b/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/clients/ton/TonChainData.kt deleted file mode 100644 index 6e90fa136..000000000 --- a/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/clients/ton/TonChainData.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.gemwallet.android.blockchain.clients.ton - -import com.gemwallet.android.model.ChainSignData -import uniffi.gemstone.GemTransactionLoadMetadata - -data class TonChainData( - val sequence: ULong, - val senderTokenAddress: String? = null, - val recipientTokenAddress: String? = null, - val expireAt: Int? = null -) : ChainSignData { - override fun toDto(): GemTransactionLoadMetadata { - return GemTransactionLoadMetadata.Ton( - sequence = sequence, - senderTokenAddress = senderTokenAddress, - recipientTokenAddress = recipientTokenAddress, - ) - } -} - -fun GemTransactionLoadMetadata.Ton.toChainData(): TonChainData { - return TonChainData( - sequence = sequence, - senderTokenAddress = senderTokenAddress, - recipientTokenAddress = recipientTokenAddress, - ) -} \ No newline at end of file diff --git a/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/clients/tron/TronChainData.kt b/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/clients/tron/TronChainData.kt deleted file mode 100644 index 1cff5dec4..000000000 --- a/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/clients/tron/TronChainData.kt +++ /dev/null @@ -1,39 +0,0 @@ -package com.gemwallet.android.blockchain.clients.tron - -import com.gemwallet.android.model.ChainSignData -import uniffi.gemstone.GemTransactionLoadMetadata -import uniffi.gemstone.TronStakeData - -data class TronChainData( - val blockNumber: ULong, - val blockVersion: ULong, - val transactionTreeRoot: String, - val witnessAddress: String, - val parentHash: String, - val blockTimestamp: ULong, - val stakeData: TronStakeData, -) : ChainSignData { - override fun toDto(): GemTransactionLoadMetadata { - return GemTransactionLoadMetadata.Tron( - blockNumber = blockNumber, - blockVersion = blockVersion, - blockTimestamp = blockTimestamp, - transactionTreeRoot = transactionTreeRoot, - witnessAddress = witnessAddress, - parentHash = parentHash, - stakeData = stakeData, - ) - } -} - -fun GemTransactionLoadMetadata.Tron.toChainData(): TronChainData { - return TronChainData( - blockNumber = blockNumber, - blockVersion = blockVersion, - blockTimestamp = blockTimestamp, - transactionTreeRoot = transactionTreeRoot, - witnessAddress = witnessAddress, - parentHash = parentHash, - stakeData = stakeData, - ) -} \ No newline at end of file diff --git a/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/clients/xrp/XrpChainData.kt b/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/clients/xrp/XrpChainData.kt deleted file mode 100644 index 058a0fc2b..000000000 --- a/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/clients/xrp/XrpChainData.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.gemwallet.android.blockchain.clients.xrp - -import com.gemwallet.android.model.ChainSignData -import uniffi.gemstone.GemTransactionLoadMetadata - -data class XrpChainData( - val sequence: ULong, - val blockNumber: ULong, -) : ChainSignData { - override fun toDto(): GemTransactionLoadMetadata { - return GemTransactionLoadMetadata.Xrp( - sequence = sequence, - blockNumber = blockNumber, - ) - } -} - -fun GemTransactionLoadMetadata.Xrp.toChainData(): XrpChainData { - return XrpChainData( - sequence = sequence, - blockNumber = blockNumber, - ) -} \ No newline at end of file diff --git a/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/gemstone/GemTransactionLoadInputMapper.kt b/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/gemstone/GemTransactionLoadInputMapper.kt index a82544600..2df126a08 100644 --- a/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/gemstone/GemTransactionLoadInputMapper.kt +++ b/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/gemstone/GemTransactionLoadInputMapper.kt @@ -1,14 +1,14 @@ package com.gemwallet.android.blockchain.gemstone -import com.gemwallet.android.model.ChainSignData import com.gemwallet.android.model.ConfirmParams import com.gemwallet.android.model.Fee import uniffi.gemstone.GemSignerInput import uniffi.gemstone.GemTransactionLoadInput +import uniffi.gemstone.GemTransactionLoadMetadata import java.math.BigInteger internal fun ConfirmParams.toGemTransactionLoadInput( - chainData: ChainSignData, + metadata: GemTransactionLoadMetadata, finalAmount: BigInteger, fee: Fee, ): GemTransactionLoadInput = GemTransactionLoadInput( @@ -19,14 +19,14 @@ internal fun ConfirmParams.toGemTransactionLoadInput( gasPrice = fee.toGemGasPriceType(), memo = memo(), isMaxValue = useMaxAmount, - metadata = chainData.toDto(), + metadata = metadata, ) internal fun ConfirmParams.toGemSignerInput( - chainData: ChainSignData, + metadata: GemTransactionLoadMetadata, finalAmount: BigInteger, fee: Fee, -): GemSignerInput = toGemTransactionLoadInput(chainData, finalAmount, fee).toGemSignerInput(fee) +): GemSignerInput = toGemTransactionLoadInput(metadata, finalAmount, fee).toGemSignerInput(fee) internal fun GemTransactionLoadInput.toGemSignerInput(fee: Fee): GemSignerInput = GemSignerInput( input = this, diff --git a/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/gemstone/GemTransactionLoadMetadataMapper.kt b/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/gemstone/GemTransactionLoadMetadataMapper.kt deleted file mode 100644 index bd3dbf66c..000000000 --- a/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/gemstone/GemTransactionLoadMetadataMapper.kt +++ /dev/null @@ -1,40 +0,0 @@ -package com.gemwallet.android.blockchain.gemstone - -import com.gemwallet.android.blockchain.clients.algorand.toChainData -import com.gemwallet.android.blockchain.clients.aptos.toChainData -import com.gemwallet.android.blockchain.clients.bitcoin.toChainData -import com.gemwallet.android.blockchain.clients.cardano.toChainData -import com.gemwallet.android.blockchain.clients.cosmos.toChainData -import com.gemwallet.android.blockchain.clients.ethereum.toChainData -import com.gemwallet.android.blockchain.clients.hyper.toChainData -import com.gemwallet.android.blockchain.clients.near.toChainData -import com.gemwallet.android.blockchain.clients.polkadot.toChainData -import com.gemwallet.android.blockchain.clients.solana.toChainData -import com.gemwallet.android.blockchain.clients.stellar.toChainData -import com.gemwallet.android.blockchain.clients.sui.toChainData -import com.gemwallet.android.blockchain.clients.ton.toChainData -import com.gemwallet.android.blockchain.clients.tron.toChainData -import com.gemwallet.android.blockchain.clients.xrp.toChainData -import com.gemwallet.android.model.ChainSignData -import uniffi.gemstone.GemTransactionLoadMetadata -import uniffi.gemstone.SwapperException.NotSupportedChain - -internal fun GemTransactionLoadMetadata.toChainData(): ChainSignData = when (this) { - is GemTransactionLoadMetadata.Algorand -> toChainData() - is GemTransactionLoadMetadata.Aptos -> toChainData() - is GemTransactionLoadMetadata.Bitcoin -> toChainData() - is GemTransactionLoadMetadata.Zcash -> toChainData() - is GemTransactionLoadMetadata.Cardano -> toChainData() - is GemTransactionLoadMetadata.Cosmos -> toChainData() - is GemTransactionLoadMetadata.Evm -> toChainData() - is GemTransactionLoadMetadata.Near -> toChainData() - is GemTransactionLoadMetadata.Polkadot -> toChainData() - is GemTransactionLoadMetadata.Solana -> toChainData() - is GemTransactionLoadMetadata.Stellar -> toChainData() - is GemTransactionLoadMetadata.Sui -> toChainData() - is GemTransactionLoadMetadata.Ton -> toChainData() - is GemTransactionLoadMetadata.Tron -> toChainData() - is GemTransactionLoadMetadata.Xrp -> toChainData() - is GemTransactionLoadMetadata.Hyperliquid -> toChainData() - GemTransactionLoadMetadata.None -> throw NotSupportedChain() -} diff --git a/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/operators/GemstoneValidateAddressOperator.kt b/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/operators/GemstoneValidateAddressOperator.kt new file mode 100644 index 000000000..864afa31d --- /dev/null +++ b/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/operators/GemstoneValidateAddressOperator.kt @@ -0,0 +1,9 @@ +package com.gemwallet.android.blockchain.operators + +import com.gemwallet.android.ext.isValidAddress +import com.wallet.core.primitives.Chain + +class GemstoneValidateAddressOperator : ValidateAddressOperator { + override operator fun invoke(address: String, chain: Chain): Result = + Result.success(chain.isValidAddress(address)) +} diff --git a/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/operators/walletcore/WCValidateAddressOperator.kt b/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/operators/walletcore/WCValidateAddressOperator.kt deleted file mode 100644 index 6fe21281a..000000000 --- a/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/operators/walletcore/WCValidateAddressOperator.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.gemwallet.android.blockchain.operators.walletcore - -import com.gemwallet.android.blockchain.operators.ValidateAddressOperator -import com.wallet.core.primitives.Chain -import wallet.core.jni.AnyAddress - -class WCValidateAddressOperator constructor() : ValidateAddressOperator { - override operator fun invoke(address: String, chain: Chain): Result - = Result.success(AnyAddress.isValid(address, WCChainTypeProxy().invoke(chain))) -} \ No newline at end of file diff --git a/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/services/SignClientProxy.kt b/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/services/SignClientProxy.kt index fd5eb7278..e8efb1706 100644 --- a/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/services/SignClientProxy.kt +++ b/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/services/SignClientProxy.kt @@ -1,9 +1,7 @@ package com.gemwallet.android.blockchain.services import com.gemwallet.android.blockchain.clients.SignClient -import com.gemwallet.android.blockchain.clients.getClient -import com.gemwallet.android.domains.asset.chain -import com.gemwallet.android.model.ChainSignData +import uniffi.gemstone.GemTransactionLoadMetadata import com.gemwallet.android.model.ConfirmParams import com.gemwallet.android.model.DestinationAddress import com.gemwallet.android.model.Fee @@ -13,7 +11,7 @@ import uniffi.gemstone.GemSwapQuoteDataType import java.math.BigInteger class SignClientProxy( - private val clients: List, + private val client: SignClient, ) { suspend fun signMessage( @@ -21,8 +19,7 @@ class SignClientProxy( input: ByteArray, privateKey: ByteArray ): ByteArray { - return clients.getClient(chain)?.signMessage(chain, input, privateKey) - ?: throw Exception("Chain isn't support") + return client.signMessage(chain, input, privateKey) } suspend fun signTypedMessage( @@ -30,46 +27,39 @@ class SignClientProxy( input: ByteArray, privateKey: ByteArray ): String { - return clients.getClient(chain)?.signTypedMessage(chain, input, privateKey) - ?: throw Exception("Chain isn't support") + return client.signTypedMessage(chain, input, privateKey) } suspend fun signTransaction( params: SignerParams, privateKey: ByteArray, ): List { - val chain = params.input.asset.id.chain - val client = clients.getClient(chain) ?: throw Exception("Chain isn't support") val input = params.input val data = params.data() val fee = data.fee - val chainData = data.chainData + val metadata = data.metadata return when (input) { - is ConfirmParams.Stake.DelegateParams -> client.signDelegate(input, chainData, params.finalAmount, fee, privateKey) - is ConfirmParams.Stake.RedelegateParams -> client.signRedelegate(input, chainData, params.finalAmount, fee, privateKey) - is ConfirmParams.Stake.RewardsParams -> client.signRewards(input, chainData, params.finalAmount, fee, privateKey) - is ConfirmParams.Stake.UndelegateParams -> client.signUndelegate(input, chainData, params.finalAmount, fee, privateKey) - is ConfirmParams.Stake.WithdrawParams -> client.signWithdraw(input, chainData, params.finalAmount, fee, privateKey) - is ConfirmParams.Stake.Freeze -> client.signFreeze(input, chainData, params.finalAmount, fee, privateKey) - is ConfirmParams.Stake.Unfreeze -> client.signUnfreeze(input, chainData, params.finalAmount, fee, privateKey) - is ConfirmParams.SwapParams -> signSwap(params.input as ConfirmParams.SwapParams, chainData, params.finalAmount, fee, privateKey, client) - is ConfirmParams.TokenApprovalParams -> client.signTokenApproval(input, chainData, params.finalAmount, fee, privateKey) - is ConfirmParams.TransferParams.Generic -> client.signGenericTransfer(input, chainData, params.finalAmount, fee, privateKey) - is ConfirmParams.TransferParams.Native -> client.signNativeTransfer(input, chainData, params.finalAmount, fee, privateKey) - is ConfirmParams.TransferParams.Token -> client.signTokenTransfer(input, chainData, params.finalAmount, fee, privateKey) - is ConfirmParams.Activate -> client.signActivate(input, chainData, params.finalAmount, fee, privateKey) - is ConfirmParams.NftParams -> client.signNft(input, chainData, params.finalAmount, fee, privateKey) - is ConfirmParams.PerpetualParams -> client.signPerpetual(input, chainData, params.finalAmount, fee, privateKey) + is ConfirmParams.Stake.DelegateParams -> client.signDelegate(input, metadata, params.finalAmount, fee, privateKey) + is ConfirmParams.Stake.RedelegateParams -> client.signRedelegate(input, metadata, params.finalAmount, fee, privateKey) + is ConfirmParams.Stake.RewardsParams -> client.signRewards(input, metadata, params.finalAmount, fee, privateKey) + is ConfirmParams.Stake.UndelegateParams -> client.signUndelegate(input, metadata, params.finalAmount, fee, privateKey) + is ConfirmParams.Stake.WithdrawParams -> client.signWithdraw(input, metadata, params.finalAmount, fee, privateKey) + is ConfirmParams.Stake.Freeze -> client.signFreeze(input, metadata, params.finalAmount, fee, privateKey) + is ConfirmParams.Stake.Unfreeze -> client.signUnfreeze(input, metadata, params.finalAmount, fee, privateKey) + is ConfirmParams.SwapParams -> signSwap(params.input as ConfirmParams.SwapParams, metadata, params.finalAmount, fee, privateKey, client) + is ConfirmParams.TokenApprovalParams -> client.signTokenApproval(input, metadata, params.finalAmount, fee, privateKey) + is ConfirmParams.TransferParams.Generic -> client.signGenericTransfer(input, metadata, params.finalAmount, fee, privateKey) + is ConfirmParams.TransferParams.Native -> client.signNativeTransfer(input, metadata, params.finalAmount, fee, privateKey) + is ConfirmParams.TransferParams.Token -> client.signTokenTransfer(input, metadata, params.finalAmount, fee, privateKey) + is ConfirmParams.Activate -> client.signActivate(input, metadata, params.finalAmount, fee, privateKey) + is ConfirmParams.NftParams -> client.signNft(input, metadata, params.finalAmount, fee, privateKey) + is ConfirmParams.PerpetualParams -> client.signPerpetual(input, metadata, params.finalAmount, fee, privateKey) } } - fun supported(chain: Chain): Boolean { - return clients.getClient(chain) != null - } - private suspend fun signSwap( params: ConfirmParams.SwapParams, - chainData: ChainSignData, + metadata: GemTransactionLoadMetadata, finalAmount: BigInteger, fee: Fee, privateKey: ByteArray, @@ -77,10 +67,10 @@ class SignClientProxy( ): List { return when (params.dataType) { - GemSwapQuoteDataType.CONTRACT -> client.signSwap(params, chainData, finalAmount, fee, privateKey) + GemSwapQuoteDataType.CONTRACT -> client.signSwap(params, metadata, finalAmount, fee, privateKey) GemSwapQuoteDataType.TRANSFER -> signSwapTransfer( params, - chainData, + metadata, finalAmount, fee, client, @@ -91,7 +81,7 @@ class SignClientProxy( private suspend fun signSwapTransfer( params: ConfirmParams.SwapParams, - chainData: ChainSignData, + metadata: GemTransactionLoadMetadata, finalAmount: BigInteger, fee: Fee, client: SignClient, @@ -112,14 +102,14 @@ class SignClientProxy( return when (transferParams) { is ConfirmParams.TransferParams.Native -> client.signNativeTransfer( transferParams, - chainData, + metadata, finalAmount, fee, privateKey, ) is ConfirmParams.TransferParams.Token -> client.signTokenTransfer( transferParams, - chainData, + metadata, finalAmount, fee, privateKey, diff --git a/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/services/SignService.kt b/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/services/SignService.kt index 3ada6d9bd..1c1d91a94 100644 --- a/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/services/SignService.kt +++ b/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/services/SignService.kt @@ -3,12 +3,10 @@ package com.gemwallet.android.blockchain.services import com.gemwallet.android.blockchain.clients.SignClient import com.gemwallet.android.blockchain.gemstone.toGemSignerInput import com.gemwallet.android.domains.asset.chain -import com.gemwallet.android.ext.toChainType -import com.gemwallet.android.model.ChainSignData +import uniffi.gemstone.GemTransactionLoadMetadata import com.gemwallet.android.model.ConfirmParams import com.gemwallet.android.model.Fee import com.wallet.core.primitives.Chain -import com.wallet.core.primitives.ChainType import uniffi.gemstone.GemChainSigner import java.math.BigInteger @@ -32,14 +30,14 @@ class SignService : SignClient { override suspend fun signActivate( params: ConfirmParams.Activate, - chainData: ChainSignData, + metadata: GemTransactionLoadMetadata, finalAmount: BigInteger, fee: Fee, privateKey: ByteArray ): List { val data = buildSignerInput( params = params, - chainData = chainData, + metadata = metadata, finalAmount = finalAmount, fee = fee, ) @@ -50,14 +48,14 @@ class SignService : SignClient { override suspend fun signDelegate( params: ConfirmParams.Stake.DelegateParams, - chainData: ChainSignData, + metadata: GemTransactionLoadMetadata, finalAmount: BigInteger, fee: Fee, privateKey: ByteArray ): List { val data = buildSignerInput( params = params, - chainData = chainData, + metadata = metadata, finalAmount = finalAmount, fee = fee, ) @@ -66,14 +64,14 @@ class SignService : SignClient { override suspend fun signFreeze( params: ConfirmParams.Stake.Freeze, - chainData: ChainSignData, + metadata: GemTransactionLoadMetadata, finalAmount: BigInteger, fee: Fee, privateKey: ByteArray ): List { val data = buildSignerInput( params = params, - chainData = chainData, + metadata = metadata, finalAmount = finalAmount, fee = fee, ) @@ -82,14 +80,14 @@ class SignService : SignClient { override suspend fun signGenericTransfer( params: ConfirmParams.TransferParams.Generic, - chainData: ChainSignData, + metadata: GemTransactionLoadMetadata, finalAmount: BigInteger, fee: Fee, privateKey: ByteArray ): List { val data = buildSignerInput( params = params, - chainData = chainData, + metadata = metadata, finalAmount = finalAmount, fee = fee, ) @@ -98,14 +96,14 @@ class SignService : SignClient { override suspend fun signNativeTransfer( params: ConfirmParams.TransferParams.Native, - chainData: ChainSignData, + metadata: GemTransactionLoadMetadata, finalAmount: BigInteger, fee: Fee, privateKey: ByteArray ): List { val data = buildSignerInput( params = params, - chainData = chainData, + metadata = metadata, finalAmount = finalAmount, fee = fee, ) @@ -116,14 +114,14 @@ class SignService : SignClient { override suspend fun signNft( params: ConfirmParams.NftParams, - chainData: ChainSignData, + metadata: GemTransactionLoadMetadata, finalAmount: BigInteger, fee: Fee, privateKey: ByteArray ): List { val data = buildSignerInput( params = params, - chainData = chainData, + metadata = metadata, finalAmount = finalAmount, fee = fee, ) @@ -134,14 +132,14 @@ class SignService : SignClient { override suspend fun signPerpetual( params: ConfirmParams.PerpetualParams, - chainData: ChainSignData, + metadata: GemTransactionLoadMetadata, finalAmount: BigInteger, fee: Fee, privateKey: ByteArray ): List { val data = buildSignerInput( params = params, - chainData = chainData, + metadata = metadata, finalAmount = finalAmount, fee = fee, ) @@ -150,14 +148,14 @@ class SignService : SignClient { override suspend fun signRedelegate( params: ConfirmParams.Stake.RedelegateParams, - chainData: ChainSignData, + metadata: GemTransactionLoadMetadata, finalAmount: BigInteger, fee: Fee, privateKey: ByteArray ): List { val data = buildSignerInput( params = params, - chainData = chainData, + metadata = metadata, finalAmount = finalAmount, fee = fee, ) @@ -166,14 +164,14 @@ class SignService : SignClient { override suspend fun signRewards( params: ConfirmParams.Stake.RewardsParams, - chainData: ChainSignData, + metadata: GemTransactionLoadMetadata, finalAmount: BigInteger, fee: Fee, privateKey: ByteArray ): List { val data = buildSignerInput( params = params, - chainData = chainData, + metadata = metadata, finalAmount = finalAmount, fee = fee, ) @@ -182,14 +180,14 @@ class SignService : SignClient { override suspend fun signSwap( params: ConfirmParams.SwapParams, - chainData: ChainSignData, + metadata: GemTransactionLoadMetadata, finalAmount: BigInteger, fee: Fee, privateKey: ByteArray ): List { val data = buildSignerInput( params = params, - chainData = chainData, + metadata = metadata, finalAmount = finalAmount, fee = fee, ) @@ -198,14 +196,14 @@ class SignService : SignClient { override suspend fun signTokenApproval( params: ConfirmParams.TokenApprovalParams, - chainData: ChainSignData, + metadata: GemTransactionLoadMetadata, finalAmount: BigInteger, fee: Fee, privateKey: ByteArray ): List { val data = buildSignerInput( params = params, - chainData = chainData, + metadata = metadata, finalAmount = finalAmount, fee = fee, ) @@ -214,14 +212,14 @@ class SignService : SignClient { override suspend fun signTokenTransfer( params: ConfirmParams.TransferParams.Token, - chainData: ChainSignData, + metadata: GemTransactionLoadMetadata, finalAmount: BigInteger, fee: Fee, privateKey: ByteArray ): List { val data = buildSignerInput( params = params, - chainData = chainData, + metadata = metadata, finalAmount = finalAmount, fee = fee, ) @@ -230,14 +228,14 @@ class SignService : SignClient { override suspend fun signUndelegate( params: ConfirmParams.Stake.UndelegateParams, - chainData: ChainSignData, + metadata: GemTransactionLoadMetadata, finalAmount: BigInteger, fee: Fee, privateKey: ByteArray ): List { val data = buildSignerInput( params = params, - chainData = chainData, + metadata = metadata, finalAmount = finalAmount, fee = fee, ) @@ -246,14 +244,14 @@ class SignService : SignClient { override suspend fun signUnfreeze( params: ConfirmParams.Stake.Unfreeze, - chainData: ChainSignData, + metadata: GemTransactionLoadMetadata, finalAmount: BigInteger, fee: Fee, privateKey: ByteArray ): List { val data = buildSignerInput( params = params, - chainData = chainData, + metadata = metadata, finalAmount = finalAmount, fee = fee, ) @@ -262,47 +260,27 @@ class SignService : SignClient { override suspend fun signWithdraw( params: ConfirmParams.Stake.WithdrawParams, - chainData: ChainSignData, + metadata: GemTransactionLoadMetadata, finalAmount: BigInteger, fee: Fee, privateKey: ByteArray ): List { val data = buildSignerInput( params = params, - chainData = chainData, + metadata = metadata, finalAmount = finalAmount, fee = fee, ) return getSigner(params).signStake(data, privateKey).map { it.toByteArray() } } - override fun supported(chain: Chain): Boolean { - return when (chain.toChainType()) { - ChainType.Ethereum, - ChainType.Solana, - ChainType.Aptos, - ChainType.Sui, - ChainType.HyperCore, - ChainType.Near, - ChainType.Algorand, - ChainType.Stellar, - ChainType.Cosmos, - ChainType.Ton, - ChainType.Polkadot, - ChainType.Xrp, - ChainType.Cardano, - ChainType.Tron -> true - else -> false - } - } - private fun buildSignerInput( params: ConfirmParams, - chainData: ChainSignData, + metadata: GemTransactionLoadMetadata, finalAmount: BigInteger, fee: Fee, ) = params.toGemSignerInput( - chainData = chainData, + metadata = metadata, finalAmount = finalAmount, fee = fee, ) diff --git a/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/services/SignerPreloaderProxy.kt b/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/services/SignerPreloaderProxy.kt index 208e0bea4..099fd1f1c 100644 --- a/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/services/SignerPreloaderProxy.kt +++ b/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/services/SignerPreloaderProxy.kt @@ -1,24 +1,18 @@ package com.gemwallet.android.blockchain.services -import com.gemwallet.android.blockchain.clients.bitcoin.BitcoinGatewayEstimateFee import com.gemwallet.android.blockchain.gemstone.selectFeeRate -import com.gemwallet.android.blockchain.gemstone.toChainData import com.gemwallet.android.blockchain.gemstone.toFee -import com.gemwallet.android.ext.toChainType import com.gemwallet.android.ext.toFeePriority import com.gemwallet.android.model.ConfirmParams import com.gemwallet.android.model.SignerParams import com.wallet.core.primitives.AssetId import com.wallet.core.primitives.Chain -import com.wallet.core.primitives.ChainType import com.wallet.core.primitives.FeePriority import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.withContext import uniffi.gemstone.GemGatewayInterface -import uniffi.gemstone.GemGatewayEstimateFee -import uniffi.gemstone.GemTransactionLoadFee import uniffi.gemstone.GemTransactionLoadInput import uniffi.gemstone.GemTransactionPreloadInput @@ -69,47 +63,13 @@ class SignerPreloaderProxy( isMaxValue = params.useMaxAmount, metadata = metadata, ), - provider = getEstimateFee(chain) ) val fee = chain.toFee(feeAssetId, selectedPriority, result.fee) - val chainData = result.metadata.toChainData() SignerParams( input = params, - selectedData = SignerParams.Data(chainData = chainData, fee = fee), + selectedData = SignerParams.Data(metadata = result.metadata, fee = fee), feeRates = validFeeRates, ) } - - private fun getEstimateFee(chain: Chain): GemGatewayEstimateFee { - return when (chain.toChainType()) { - ChainType.Bitcoin -> BitcoinGatewayEstimateFee() - ChainType.Ethereum, - ChainType.Cardano, - ChainType.Solana, - ChainType.Cosmos, - ChainType.Ton, - ChainType.Tron, - ChainType.Aptos, - ChainType.Sui, - ChainType.Xrp, - ChainType.Near, - ChainType.Stellar, - ChainType.Algorand, - ChainType.Polkadot, - ChainType.HyperCore -> StubGatewayEstimateFee - } - } - - private object StubGatewayEstimateFee : GemGatewayEstimateFee { - override suspend fun getFee( - chain: uniffi.gemstone.Chain, - input: GemTransactionLoadInput - ): GemTransactionLoadFee? = null - - override suspend fun getFeeData( - chain: uniffi.gemstone.Chain, - input: GemTransactionLoadInput - ): String? = null - } } diff --git a/android/blockchain/src/test/kotlin/com/gemwallet/android/blockchain/services/SignClientProxyTest.kt b/android/blockchain/src/test/kotlin/com/gemwallet/android/blockchain/services/SignClientProxyTest.kt index fed7f15a1..e8a3bc25e 100644 --- a/android/blockchain/src/test/kotlin/com/gemwallet/android/blockchain/services/SignClientProxyTest.kt +++ b/android/blockchain/src/test/kotlin/com/gemwallet/android/blockchain/services/SignClientProxyTest.kt @@ -1,8 +1,6 @@ package com.gemwallet.android.blockchain.services import com.gemwallet.android.blockchain.clients.SignClient -import com.gemwallet.android.blockchain.clients.cosmos.CosmosChainData -import com.gemwallet.android.model.ChainSignData import com.gemwallet.android.model.ConfirmParams import com.gemwallet.android.model.Fee import com.gemwallet.android.model.SignerParams @@ -15,6 +13,7 @@ import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals import org.junit.Test import uniffi.gemstone.GemSwapQuoteDataType +import uniffi.gemstone.GemTransactionLoadMetadata import uniffi.gemstone.SwapperProvider import java.math.BigInteger @@ -27,7 +26,7 @@ class SignClientProxyTest { val toAsset = mockAsset(chain = Chain.Cosmos, name = "Cosmos", symbol = "ATOM", decimals = 6) val fromAmount = BigInteger("2564989685") val finalAmount = BigInteger("2562989685") - val client = RecordingSignClient(chain) + val client = RecordingSignClient() val params = ConfirmParams.SwapParams( from = mockAccount(chain = chain, address = "thor1sender"), fromAsset = fromAsset, @@ -58,9 +57,9 @@ class SignClientProxyTest { limit = BigInteger("200000"), options = emptyMap(), ), - chainData = CosmosChainData( - chainId = "thorchain-mainnet-v1", + metadata = GemTransactionLoadMetadata.Cosmos( accountNumber = 1uL, + chainId = "thorchain-mainnet-v1", sequence = 3uL, ), ), @@ -68,7 +67,7 @@ class SignClientProxyTest { finalAmount = finalAmount, ) - SignClientProxy(listOf(client)).signTransaction(signerParams, byteArrayOf()) + SignClientProxy(client).signTransaction(signerParams, byteArrayOf()) assertEquals(finalAmount, client.nativeTransferFinalAmount) assertEquals(finalAmount, client.nativeTransferParams?.amount) @@ -76,13 +75,13 @@ class SignClientProxyTest { assertEquals("=:o:cosmos1recipient:0/1/0:g1:50", client.nativeTransferParams?.memo) } - private class RecordingSignClient(private val chain: Chain) : SignClient { + private class RecordingSignClient : SignClient { var nativeTransferParams: ConfirmParams.TransferParams.Native? = null var nativeTransferFinalAmount: BigInteger? = null override suspend fun signNativeTransfer( params: ConfirmParams.TransferParams.Native, - chainData: ChainSignData, + metadata: GemTransactionLoadMetadata, finalAmount: BigInteger, fee: Fee, privateKey: ByteArray, @@ -91,7 +90,5 @@ class SignClientProxyTest { nativeTransferFinalAmount = finalAmount return emptyList() } - - override fun supported(chain: Chain): Boolean = this.chain == chain } } diff --git a/android/blockchain/src/test/kotlin/com/gemwallet/android/blockchain/services/SignerPreloaderProxyTest.kt b/android/blockchain/src/test/kotlin/com/gemwallet/android/blockchain/services/SignerPreloaderProxyTest.kt index 8a2b71f0f..84d67f8ab 100644 --- a/android/blockchain/src/test/kotlin/com/gemwallet/android/blockchain/services/SignerPreloaderProxyTest.kt +++ b/android/blockchain/src/test/kotlin/com/gemwallet/android/blockchain/services/SignerPreloaderProxyTest.kt @@ -44,7 +44,7 @@ class SignerPreloaderProxyTest { coEvery { gateway.getTransactionPreload(any(), any()) } returns metadata coEvery { gateway.getFeeRates(any(), any()) } returns feeRates - coEvery { gateway.getTransactionLoad(any(), capture(loadInput), any()) } returns GemTransactionData( + coEvery { gateway.getTransactionLoad(any(), capture(loadInput)) } returns GemTransactionData( fee = GemTransactionLoadFee( fee = "21000", gasPriceType = feeRates[1].gasPriceType, @@ -62,7 +62,7 @@ class SignerPreloaderProxyTest { assertEquals(feeRates[1].gasPriceType, loadInput.captured.gasPrice) coVerify(exactly = 1) { gateway.getTransactionPreload(any(), any()) } coVerify(exactly = 1) { gateway.getFeeRates(any(), any()) } - coVerify(exactly = 1) { gateway.getTransactionLoad(any(), any(), any()) } + coVerify(exactly = 1) { gateway.getTransactionLoad(any(), any()) } } @Test @@ -81,7 +81,7 @@ class SignerPreloaderProxyTest { coEvery { gateway.getTransactionPreload(any(), any()) } returns metadata coEvery { gateway.getFeeRates(any(), any()) } returns feeRates - coEvery { gateway.getTransactionLoad(any(), capture(loadInput), any()) } returns GemTransactionData( + coEvery { gateway.getTransactionLoad(any(), capture(loadInput)) } returns GemTransactionData( fee = GemTransactionLoadFee( fee = "21000", gasPriceType = feeRates[1].gasPriceType, @@ -96,7 +96,7 @@ class SignerPreloaderProxyTest { assertEquals(listOf(feeRates[1]), result.feeRates) assertEquals(FeePriority.Fast, result.fee().priority) assertEquals(feeRates[1].gasPriceType, loadInput.captured.gasPrice) - coVerify(exactly = 1) { gateway.getTransactionLoad(any(), any(), any()) } + coVerify(exactly = 1) { gateway.getTransactionLoad(any(), any()) } } private fun transferParams(): ConfirmParams { diff --git a/android/data/coordinators/src/main/kotlin/com/gemwallet/android/data/coordinators/confirm/ConfirmTransactionImpl.kt b/android/data/coordinators/src/main/kotlin/com/gemwallet/android/data/coordinators/confirm/ConfirmTransactionImpl.kt index 63541e9ee..c0a902e09 100644 --- a/android/data/coordinators/src/main/kotlin/com/gemwallet/android/data/coordinators/confirm/ConfirmTransactionImpl.kt +++ b/android/data/coordinators/src/main/kotlin/com/gemwallet/android/data/coordinators/confirm/ConfirmTransactionImpl.kt @@ -13,6 +13,7 @@ import com.gemwallet.android.model.ConfirmParams import com.gemwallet.android.model.RecentType import com.gemwallet.android.model.Session import com.gemwallet.android.model.SignerParams +import com.gemwallet.android.model.blockNumber import com.gemwallet.android.serializer.jsonEncoder import com.wallet.core.primitives.TransactionDirection import com.wallet.core.primitives.TransactionNFTTransferMetadata @@ -118,7 +119,7 @@ class ConfirmTransactionImpl( } else { TransactionDirection.Outgoing }, - blockNumber = signerParams.data().chainData.blockNumber() + blockNumber = signerParams.data().metadata.blockNumber() ) } diff --git a/android/data/coordinators/src/test/kotlin/com/gemwallet/android/data/coordinators/confirm/ValidateBalanceImplTest.kt b/android/data/coordinators/src/test/kotlin/com/gemwallet/android/data/coordinators/confirm/ValidateBalanceImplTest.kt index 82df167ed..699357901 100644 --- a/android/data/coordinators/src/test/kotlin/com/gemwallet/android/data/coordinators/confirm/ValidateBalanceImplTest.kt +++ b/android/data/coordinators/src/test/kotlin/com/gemwallet/android/data/coordinators/confirm/ValidateBalanceImplTest.kt @@ -3,7 +3,6 @@ package com.gemwallet.android.data.coordinators.confirm import com.gemwallet.android.domains.confirm.ConfirmError import com.gemwallet.android.ext.getMinimumAccountBalance import com.gemwallet.android.model.AssetBalance -import com.gemwallet.android.model.ChainSignData import com.gemwallet.android.model.ConfirmParams import com.gemwallet.android.model.DestinationAddress import com.gemwallet.android.model.Fee @@ -25,6 +24,7 @@ import org.junit.Assert.assertThrows import org.junit.Before import org.junit.Test import uniffi.gemstone.GemSwapQuoteDataType +import uniffi.gemstone.GemTransactionLoadMetadata import uniffi.gemstone.SwapperProvider import java.math.BigInteger @@ -109,7 +109,7 @@ class ValidateBalanceImplTest { input = params, selectedData = SignerParams.Data( fee = Fee.Plain(asset.id, FeePriority.Normal, feeAmount, emptyMap()), - chainData = mockk(), + metadata = GemTransactionLoadMetadata.None, ), feeRates = emptyList(), finalAmount = tokenAmount, @@ -176,7 +176,7 @@ class ValidateBalanceImplTest { input = params, selectedData = SignerParams.Data( fee = Fee.Plain(asset.id, FeePriority.Normal, feeAmount, emptyMap()), - chainData = mockk(), + metadata = GemTransactionLoadMetadata.None, ), feeRates = emptyList(), finalAmount = finalAmount, diff --git a/android/gemcore/src/main/kotlin/com/gemwallet/android/domains/asset/AssetMappers.kt b/android/gemcore/src/main/kotlin/com/gemwallet/android/domains/asset/AssetMappers.kt index 53ac73e0c..f359c224e 100644 --- a/android/gemcore/src/main/kotlin/com/gemwallet/android/domains/asset/AssetMappers.kt +++ b/android/gemcore/src/main/kotlin/com/gemwallet/android/domains/asset/AssetMappers.kt @@ -101,13 +101,6 @@ fun GemAsset.toDTO() = Asset( } ) -fun GemUtxo.toUtxo(): UTXO = UTXO( - transactionId, - vout, - value, - address -) - fun UTXO.toGem(): GemUtxo = GemUtxo( transaction_id, vout, @@ -115,6 +108,4 @@ fun UTXO.toGem(): GemUtxo = GemUtxo( address ) -fun List.toUtxo() = map { it.toUtxo() } - fun List.toGem() = map { it.toGem() } diff --git a/android/gemcore/src/main/kotlin/com/gemwallet/android/model/SignerParams.kt b/android/gemcore/src/main/kotlin/com/gemwallet/android/model/SignerParams.kt index 14116dc8f..934abd6d6 100644 --- a/android/gemcore/src/main/kotlin/com/gemwallet/android/model/SignerParams.kt +++ b/android/gemcore/src/main/kotlin/com/gemwallet/android/model/SignerParams.kt @@ -16,12 +16,12 @@ data class SignerParams( data class Data( val fee: Fee, - val chainData: ChainSignData, + val metadata: GemTransactionLoadMetadata, ) } -interface ChainSignData { - fun blockNumber(): String = "" - - fun toDto(): GemTransactionLoadMetadata +fun GemTransactionLoadMetadata.blockNumber(): String = when (this) { + is GemTransactionLoadMetadata.Cardano -> blockNumber.toString() + is GemTransactionLoadMetadata.Polkadot -> blockNumber.toString() + else -> "" } diff --git a/core/Cargo.lock b/core/Cargo.lock index c621c455a..9374da6da 100644 --- a/core/Cargo.lock +++ b/core/Cargo.lock @@ -864,6 +864,12 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + [[package]] name = "arrayvec" version = "0.7.6" @@ -1161,6 +1167,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" +[[package]] +name = "base58ck" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c8d66485a3a2ea485c1913c4572ce0256067a5377ac8c75c4960e1cda98605f" +dependencies = [ + "bitcoin-internals", + "bitcoin_hashes 0.14.1", +] + [[package]] name = "base64" version = "0.22.1" @@ -1241,12 +1257,49 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +[[package]] +name = "bitcoin" +version = "0.32.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39581299241111285f3268ba75ddf372746fd041620918b145c1af9d75e91b6c" +dependencies = [ + "base58ck", + "bech32", + "bitcoin-io", + "bitcoin-units", + "bitcoin_hashes 0.14.1", + "hex-conservative", + "hex_lit", + "secp256k1 0.29.1", +] + +[[package]] +name = "bitcoin-internals" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30bdbe14aa07b06e6cfeffc529a1f099e5fbe249524f8125358604df99a4bed2" + [[package]] name = "bitcoin-io" version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2dee39a0ee5b4095224a0cfc6bf4cc1baf0f9624b96b367e53b66d974e51d953" +[[package]] +name = "bitcoin-units" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346568ebaab2918487cea76dd55dae13c27bb618cdb737c952e69eb2017c4118" +dependencies = [ + "bitcoin-internals", +] + +[[package]] +name = "bitcoin_hashes" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b375d62f341cef9cd9e77793ec8f1db3fc9ce2e4d57e982c8fe697a2c16af3b6" + [[package]] name = "bitcoin_hashes" version = "0.14.1" @@ -1257,6 +1310,15 @@ dependencies = [ "hex-conservative", ] +[[package]] +name = "bitcoincash-addr" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad79afbfd27efc52fc928b198a365a7ee9da8d881a18c16d88764880b675e543" +dependencies = [ + "bitcoin_hashes 0.7.6", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -1293,6 +1355,17 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "blake2b_simd" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b79834656f71332577234b50bfc009996f7449e0c056884e6a02492ded0ca2f3" +dependencies = [ + "arrayref", + "arrayvec", + "constant_time_eq", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -1814,6 +1887,12 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + [[package]] name = "convert_case" version = "0.6.0" @@ -3103,6 +3182,10 @@ name = "gem_bitcoin" version = "1.0.0" dependencies = [ "async-trait", + "bech32", + "bitcoin", + "bitcoincash-addr", + "bs58", "chain_traits", "chrono", "futures", @@ -3242,7 +3325,7 @@ dependencies = [ name = "gem_hash" version = "1.0.0" dependencies = [ - "blake2", + "blake2b_simd", "hex", "sha2 0.11.0", "sha3 0.12.0", @@ -3800,6 +3883,12 @@ dependencies = [ "arrayvec", ] +[[package]] +name = "hex_lit" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3011d1213f159867b13cfd6ac92d2cd5f1345762c63be3554e84092d85a50bbd" + [[package]] name = "hickory-proto" version = "0.25.2" @@ -6612,13 +6701,23 @@ dependencies = [ "zeroize", ] +[[package]] +name = "secp256k1" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113" +dependencies = [ + "bitcoin_hashes 0.14.1", + "secp256k1-sys 0.10.1", +] + [[package]] name = "secp256k1" version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b50c5943d326858130af85e049f2661ba3c78b26589b8ab98e65e80ae44a1252" dependencies = [ - "bitcoin_hashes", + "bitcoin_hashes 0.14.1", "rand 0.8.5", "secp256k1-sys 0.10.1", "serde", @@ -6630,7 +6729,7 @@ version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2c3c81b43dc2d8877c216a3fccf76677ee1ebccd429566d3e67447290d0c42b2" dependencies = [ - "bitcoin_hashes", + "bitcoin_hashes 0.14.1", "rand 0.9.2", "secp256k1-sys 0.11.0", ] diff --git a/core/Cargo.toml b/core/Cargo.toml index 7747a708c..67cf362f4 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -93,7 +93,9 @@ chrono = { version = "0.4.43", features = ["serde"] } # crypto base64 = { version = "0.22.1" } bech32 = { version = "0.11.1" } -blake2 = { version = "0.10.6" } +bitcoin = { version = "0.32.100" } +bitcoincash-addr = { version = "0.5.2" } +blake2b_simd = { version = "1.0.4" } bs58 = { version = "0.5.1", features = ["check"] } hex = { version = "0.4.3" } crc = { version = "3.4.0" } diff --git a/core/crates/gem_aptos/src/signer/chain_signer.rs b/core/crates/gem_aptos/src/signer/chain_signer.rs index d8cf842d9..841e43ccc 100644 --- a/core/crates/gem_aptos/src/signer/chain_signer.rs +++ b/core/crates/gem_aptos/src/signer/chain_signer.rs @@ -228,7 +228,7 @@ mod tests { match err { SignerError::InvalidInput(message) => assert_eq!(message, "Invalid Aptos token ID format"), - SignerError::SigningError(message) => panic!("unexpected signing error: {message}"), + other => panic!("unexpected error: {other:?}"), } } } diff --git a/core/crates/gem_bitcoin/Cargo.toml b/core/crates/gem_bitcoin/Cargo.toml index 66c73a36d..098a0390f 100644 --- a/core/crates/gem_bitcoin/Cargo.toml +++ b/core/crates/gem_bitcoin/Cargo.toml @@ -6,8 +6,8 @@ publish = false [features] default = [] -rpc = ["dep:chain_traits", "dep:gem_client"] -signer = ["dep:signer", "dep:gem_hash", "dep:hex"] +rpc = ["dep:chain_traits", "dep:gem_client", "signer"] +signer = ["dep:bech32", "dep:bitcoin", "dep:bitcoincash-addr", "dep:bs58", "dep:gem_hash", "dep:hex", "dep:signer"] reqwest = ["gem_client/reqwest"] unit_tests = ["signer"] chain_integration_tests = ["rpc", "reqwest", "primitives/testkit", "settings/testkit"] @@ -29,8 +29,14 @@ serde_serializers = { path = "../serde_serializers", features = ["bigint"] } signer = { path = "../signer", optional = true } gem_hash = { path = "../gem_hash", optional = true } hex = { workspace = true, optional = true } +bitcoin = { workspace = true, optional = true } +bitcoincash-addr = { workspace = true, optional = true } +bech32 = { workspace = true, optional = true } +bs58 = { workspace = true, optional = true } [dev-dependencies] +gem_client = { path = "../gem_client", features = ["testkit"] } tokio = { workspace = true, features = ["macros", "rt"] } reqwest = { workspace = true } +primitives = { path = "../primitives", features = ["testkit"] } settings = { path = "../settings", features = ["testkit"] } diff --git a/core/crates/gem_bitcoin/src/address.rs b/core/crates/gem_bitcoin/src/address.rs new file mode 100644 index 000000000..04bb7d2a9 --- /dev/null +++ b/core/crates/gem_bitcoin/src/address.rs @@ -0,0 +1,85 @@ +use bitcoin::ScriptBuf; +use primitives::{Address as AddressTrait, BitcoinChain, Chain}; + +use crate::signer::address::script_for_address; + +#[derive(Debug, Clone)] +pub struct BitcoinAddress { + chain: BitcoinChain, + address: String, + script_pubkey: ScriptBuf, +} + +impl BitcoinAddress { + pub fn try_parse_for_chain(address: &str, chain: BitcoinChain) -> Option { + let script_pubkey = script_for_address(chain, address).ok()?.script_pubkey; + Some(Self { + chain, + address: address.to_string(), + script_pubkey, + }) + } + + pub fn is_valid_for_chain(address: &str, chain: Chain) -> bool { + BitcoinChain::from_chain(chain).is_some_and(|chain| Self::try_parse_for_chain(address, chain).is_some()) + } + + pub fn bitcoin_chain(&self) -> BitcoinChain { + self.chain + } +} + +impl AddressTrait for BitcoinAddress { + fn try_parse(address: &str) -> Option { + [ + BitcoinChain::Bitcoin, + BitcoinChain::BitcoinCash, + BitcoinChain::Litecoin, + BitcoinChain::Doge, + BitcoinChain::Zcash, + ] + .into_iter() + .find_map(|chain| Self::try_parse_for_chain(address, chain)) + } + + fn as_bytes(&self) -> &[u8] { + self.script_pubkey.as_bytes() + } + + fn encode(&self) -> String { + self.address.clone() + } +} + +pub fn validate_address(address: &str, chain: Chain) -> bool { + BitcoinAddress::is_valid_for_chain(address, chain) +} + +#[cfg(test)] +mod tests { + use super::*; + use primitives::{Address as AddressTrait, BITCOINCASH_PREFIX}; + + #[test] + fn test_validate_address() { + let bitcoin = BitcoinAddress::mock(); + let bitcoin_cash = BitcoinAddress::mock_with_chain(BitcoinChain::BitcoinCash); + let litecoin = BitcoinAddress::mock_with_chain(BitcoinChain::Litecoin); + let doge = BitcoinAddress::mock_with_chain(BitcoinChain::Doge); + let zcash = BitcoinAddress::mock_with_chain(BitcoinChain::Zcash); + + assert!(validate_address(&bitcoin.encode(), Chain::Bitcoin)); + assert!(validate_address(&bitcoin_cash.encode(), Chain::BitcoinCash)); + assert!(validate_address(bitcoin_cash.encode().strip_prefix(BITCOINCASH_PREFIX).unwrap(), Chain::BitcoinCash)); + assert!(validate_address(&litecoin.encode(), Chain::Litecoin)); + assert!(validate_address(&doge.encode(), Chain::Doge)); + assert!(validate_address(&zcash.encode(), Chain::Zcash)); + assert!(!validate_address(&bitcoin.encode(), Chain::Litecoin)); + assert!(!validate_address("invalid", Chain::Bitcoin)); + + let parsed = BitcoinAddress::try_parse_for_chain(&bitcoin.encode(), BitcoinChain::Bitcoin).unwrap(); + assert_eq!(parsed.bitcoin_chain().get_chain(), Chain::Bitcoin); + assert_eq!(parsed.encode(), bitcoin.encode()); + assert_eq!(hex::encode(parsed.as_bytes()), "0014751e76e8199196d454941c45d1b3a323f1433bd6"); + } +} diff --git a/core/crates/gem_bitcoin/src/hash.rs b/core/crates/gem_bitcoin/src/hash.rs new file mode 100644 index 000000000..befe093f1 --- /dev/null +++ b/core/crates/gem_bitcoin/src/hash.rs @@ -0,0 +1,34 @@ +use bitcoin::hashes::{Hash, hash160 as bitcoin_hash160, sha256d}; +use primitives::SignerError; + +pub(crate) const HASH160_LEN: usize = 20; + +pub(crate) fn double_sha256(bytes: &[u8]) -> [u8; 32] { + sha256d::Hash::hash(bytes).to_byte_array() +} + +pub(crate) fn hash160(bytes: &[u8]) -> [u8; HASH160_LEN] { + bitcoin_hash160::Hash::hash(bytes).to_byte_array() +} + +pub(crate) fn public_key_hash(public_key: &[u8]) -> [u8; HASH160_LEN] { + hash160(public_key) +} + +pub(crate) fn hash20(bytes: &[u8]) -> Result<[u8; HASH160_LEN], SignerError> { + bytes.try_into().map_err(SignerError::from_display) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_hashes() { + assert_eq!(hex::encode(double_sha256(b"")), "5df6e0e2761359d30a8275058e299fcc0381534545f55cf43e41983f5d4c9456"); + assert_eq!(hex::encode(hash160(b"")), "b472a266d0bd89c13706a4132ccfb16f7c3b9fcb"); + assert_eq!(public_key_hash(b""), hash160(b"")); + assert_eq!(hash20(&[1u8; HASH160_LEN]).unwrap(), [1u8; HASH160_LEN]); + assert!(hash20(&[1u8; HASH160_LEN - 1]).is_err()); + } +} diff --git a/core/crates/gem_bitcoin/src/lib.rs b/core/crates/gem_bitcoin/src/lib.rs index 07e4f514b..9ffcd9018 100644 --- a/core/crates/gem_bitcoin/src/lib.rs +++ b/core/crates/gem_bitcoin/src/lib.rs @@ -1,5 +1,11 @@ pub mod models; +#[cfg(feature = "signer")] +pub(crate) mod hash; + +#[cfg(feature = "signer")] +pub mod address; + #[cfg(feature = "rpc")] pub mod provider; @@ -15,5 +21,8 @@ pub mod testkit; #[cfg(feature = "rpc")] pub use provider::map_transaction; +#[cfg(feature = "signer")] +pub use address::{BitcoinAddress, validate_address}; + #[cfg(feature = "rpc")] pub use rpc::client::BitcoinClient; diff --git a/core/crates/gem_bitcoin/src/models/address.rs b/core/crates/gem_bitcoin/src/models/address.rs index e76071da6..940300ad0 100644 --- a/core/crates/gem_bitcoin/src/models/address.rs +++ b/core/crates/gem_bitcoin/src/models/address.rs @@ -1,6 +1,4 @@ -use primitives::chain::Chain; - -const BITCOINCASH_PREFIX: &str = "bitcoincash:"; +use primitives::{BITCOINCASH_PREFIX, chain::Chain}; pub struct Address { value: String, diff --git a/core/crates/gem_bitcoin/src/provider/preload.rs b/core/crates/gem_bitcoin/src/provider/preload.rs index 5cce4332c..842ee2381 100644 --- a/core/crates/gem_bitcoin/src/provider/preload.rs +++ b/core/crates/gem_bitcoin/src/provider/preload.rs @@ -1,6 +1,5 @@ use async_trait::async_trait; use chain_traits::ChainTransactionLoad; -use futures; use num_bigint::BigInt; use number_formatter::BigNumberFormatter; use std::error::Error; @@ -13,6 +12,7 @@ use primitives::{ use crate::models::Address; use crate::provider::preload_mapper::{map_transaction_preload, map_transaction_preload_zcash, map_utxos}; use crate::rpc::client::BitcoinClient; +use crate::signer::estimate_transaction_fee; #[async_trait] impl ChainTransactionLoad for BitcoinClient { @@ -32,10 +32,9 @@ impl ChainTransactionLoad for BitcoinClient { } async fn get_transaction_load(&self, input: TransactionLoadInput) -> Result> { - Ok(TransactionLoadData { - fee: input.default_fee(), - metadata: input.metadata, - }) + let fee = estimate_transaction_fee(self.chain, &input)?; + + Ok(TransactionLoadData { fee, metadata: input.metadata }) } async fn get_transaction_fee_rates(&self, _input_type: TransactionInputType) -> Result, Box> { @@ -45,9 +44,7 @@ impl ChainTransactionLoad for BitcoinClient { let (slow, normal, fast) = futures::try_join!(self.get_fee(priority.slow), self.get_fee(priority.normal), self.get_fee(priority.fast))?; Ok(map_fee_rates(slow, normal, fast, self.chain)) } - BitcoinChain::Zcash => { - return Ok(vec![FeeRate::new(FeePriority::Normal, GasPriceType::regular(BigInt::from(1_000).clone()))]); - } + BitcoinChain::Zcash => Ok(vec![FeeRate::new(FeePriority::Normal, GasPriceType::regular(BigInt::from(1_000)))]), } } @@ -86,6 +83,13 @@ fn map_fee_rates(slow: BigInt, normal: BigInt, fast: BigInt, chain: BitcoinChain #[cfg(test)] mod tests { use super::*; + use gem_client::testkit::MockClient; + use primitives::TransactionLoadMetadata; + + use crate::{ + rpc::client::BitcoinClient, + testkit::signer_mock::{mock_transfer_input, mock_utxo_with}, + }; #[test] fn test_calculate_fee_rate() { @@ -120,4 +124,28 @@ mod tests { assert_eq!(FeeRate::find(&rates, FeePriority::Normal).unwrap().gas_price_type.gas_price(), BigInt::from(2000)); assert_eq!(FeeRate::find(&rates, FeePriority::Fast).unwrap().gas_price_type.gas_price(), BigInt::from(3000)); } + + #[tokio::test] + async fn test_get_transaction_load_estimates_with_signer_planner() { + let client = BitcoinClient::new(MockClient::new(), BitcoinChain::BitcoinCash); + let mut input = mock_transfer_input(BitcoinChain::BitcoinCash).input; + input.metadata = TransactionLoadMetadata::Bitcoin { + utxos: vec![mock_utxo_with( + "0000000000000000000000000000000000000000000000000000000000000001", + 0, + "50000", + &input.sender_address, + )], + }; + + let load = client.get_transaction_load(input).await.unwrap(); + + assert_eq!(load.fee.fee, BigInt::from(1130u64)); + + let client = BitcoinClient::new(MockClient::new(), BitcoinChain::Zcash); + let input = mock_transfer_input(BitcoinChain::Zcash).input; + let load = client.get_transaction_load(input).await.unwrap(); + + assert_eq!(load.fee.fee, BigInt::from(10000u64)); + } } diff --git a/core/crates/gem_bitcoin/src/signer/address/bitcoin.rs b/core/crates/gem_bitcoin/src/signer/address/bitcoin.rs new file mode 100644 index 000000000..31fd8a0d8 --- /dev/null +++ b/core/crates/gem_bitcoin/src/signer/address/bitcoin.rs @@ -0,0 +1,26 @@ +use std::str::FromStr; + +use ::bitcoin::{Address, AddressType, Network, address::AddressData}; +use primitives::SignerError; + +use super::script::{AddressScript, LockingScript}; + +pub(super) fn script(address: &str) -> Result { + let address = Address::from_str(address) + .map_err(SignerError::from_display)? + .require_network(Network::Bitcoin) + .map_err(SignerError::from_display)?; + let script_pubkey = address.script_pubkey(); + + match address.to_address_data() { + AddressData::P2pkh { .. } => Ok(AddressScript::new(script_pubkey, LockingScript::P2pkh)), + AddressData::P2sh { .. } => Ok(AddressScript::new(script_pubkey, LockingScript::P2sh)), + AddressData::Segwit { .. } => match address.address_type() { + Some(AddressType::P2wpkh) => Ok(AddressScript::new(script_pubkey, LockingScript::P2wpkh)), + Some(AddressType::P2wsh) => Ok(AddressScript::new(script_pubkey, LockingScript::P2wsh)), + Some(AddressType::P2tr) => Ok(AddressScript::new(script_pubkey, LockingScript::P2tr)), + _ => Err(SignerError::from_display("unsupported Bitcoin address type")), + }, + _ => Err(SignerError::from_display("unsupported Bitcoin address type")), + } +} diff --git a/core/crates/gem_bitcoin/src/signer/address/bitcoin_cash.rs b/core/crates/gem_bitcoin/src/signer/address/bitcoin_cash.rs new file mode 100644 index 000000000..97b5e4606 --- /dev/null +++ b/core/crates/gem_bitcoin/src/signer/address/bitcoin_cash.rs @@ -0,0 +1,50 @@ +use std::fmt; + +use bitcoincash_addr::{ + Address as CashAddress, HashType as CashHashType, Network as CashNetwork, base58::DecodingError as Base58DecodingError, cashaddr::DecodingError as CashaddrDecodingError, +}; +use primitives::{BITCOINCASH_PREFIX, SignerError}; + +use super::script::{AddressScript, LockingScript, p2pkh_script, p2sh_script}; +use crate::hash::hash20; + +struct DecodeAddressError { + cashaddr: CashaddrDecodingError, + base58: Base58DecodingError, +} + +impl From<(CashaddrDecodingError, Base58DecodingError)> for DecodeAddressError { + fn from((cashaddr, base58): (CashaddrDecodingError, Base58DecodingError)) -> Self { + Self { cashaddr, base58 } + } +} + +impl fmt::Display for DecodeAddressError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "invalid Bitcoin Cash address: cashaddr: {}; base58: {}", self.cashaddr, self.base58) + } +} + +pub(super) fn script(address: &str) -> Result { + let address = decode_address(address).map_err(SignerError::from_display)?; + if address.network != CashNetwork::Main { + return Err(SignerError::from_display("unsupported Bitcoin Cash address network")); + } + let hash = hash20(&address.body)?; + match address.hash_type { + CashHashType::Key => Ok(AddressScript::new(p2pkh_script(hash), LockingScript::P2pkh)), + CashHashType::Script => Ok(AddressScript::new(p2sh_script(hash), LockingScript::P2sh)), + } +} + +fn decode_address(address: &str) -> Result { + match CashAddress::decode(address) { + Ok(address) => Ok(address), + Err(error) => { + if address.contains(':') { + return Err(error.into()); + } + CashAddress::decode(&format!("{BITCOINCASH_PREFIX}{address}")).map_err(|_| error.into()) + } + } +} diff --git a/core/crates/gem_bitcoin/src/signer/address/doge.rs b/core/crates/gem_bitcoin/src/signer/address/doge.rs new file mode 100644 index 000000000..820e26d74 --- /dev/null +++ b/core/crates/gem_bitcoin/src/signer/address/doge.rs @@ -0,0 +1,10 @@ +use primitives::SignerError; + +use super::script::AddressScript; + +const P2PKH_VERSIONS: [u8; 1] = [30]; +const P2SH_VERSIONS: [u8; 1] = [22]; + +pub(super) fn script(address: &str) -> Result { + AddressScript::from_prefixed_address(address, &P2PKH_VERSIONS, &P2SH_VERSIONS, None) +} diff --git a/core/crates/gem_bitcoin/src/signer/address/litecoin.rs b/core/crates/gem_bitcoin/src/signer/address/litecoin.rs new file mode 100644 index 000000000..2e2a124ee --- /dev/null +++ b/core/crates/gem_bitcoin/src/signer/address/litecoin.rs @@ -0,0 +1,12 @@ +use primitives::SignerError; + +use super::script::AddressScript; + +const P2PKH_VERSIONS: [u8; 1] = [48]; +// Modern LTC P2SH (50, `M…`) only; legacy 5 (`3…`) collides with Bitcoin mainnet P2SH. +const P2SH_VERSIONS: [u8; 1] = [50]; +const HRP: &str = "ltc"; + +pub(super) fn script(address: &str) -> Result { + AddressScript::from_prefixed_address(address, &P2PKH_VERSIONS, &P2SH_VERSIONS, Some(HRP)) +} diff --git a/core/crates/gem_bitcoin/src/signer/address/mod.rs b/core/crates/gem_bitcoin/src/signer/address/mod.rs new file mode 100644 index 000000000..603335443 --- /dev/null +++ b/core/crates/gem_bitcoin/src/signer/address/mod.rs @@ -0,0 +1,209 @@ +mod bitcoin; +mod bitcoin_cash; +mod doge; +mod litecoin; +mod script; +mod zcash; + +use primitives::{BitcoinChain, SignerError}; + +pub(crate) use crate::hash::public_key_hash; +use script::AddressScript; +pub(crate) use script::{UnlockingScript, script_for_public_key_hash}; +#[cfg(test)] +pub(crate) use zcash::TRANSPARENT_P2PKH_PREFIX as ZCASH_TRANSPARENT_P2PKH_PREFIX; + +pub(crate) fn script_for_address(chain: BitcoinChain, address: &str) -> Result { + match chain { + BitcoinChain::Bitcoin => bitcoin::script(address), + BitcoinChain::Litecoin => litecoin::script(address), + BitcoinChain::Doge => doge::script(address), + BitcoinChain::BitcoinCash => bitcoin_cash::script(address), + BitcoinChain::Zcash => zcash::script(address), + } +} + +#[cfg(test)] +mod tests { + use primitives::BitcoinChain; + + use super::{ + script::{LockingScript, UnlockingScript}, + script_for_address, + }; + + struct AddressScriptCase { + address: &'static str, + locking_script: LockingScript, + unlocking_script: Option, + script_pubkey: &'static str, + public_key_hash: Option<&'static str>, + } + + impl AddressScriptCase { + fn assert(&self, chain: BitcoinChain) { + let script = script_for_address(chain, self.address).unwrap(); + assert_eq!(script.locking_script, self.locking_script, "locking_script for {}", self.address); + assert_eq!(script.unlocking_script(), self.unlocking_script, "unlocking_script for {}", self.address); + assert_eq!(hex::encode(script.script_pubkey.as_bytes()), self.script_pubkey, "script_pubkey for {}", self.address); + assert_eq!( + script.public_key_hash().map(hex::encode).as_deref(), + self.public_key_hash, + "public_key_hash for {}", + self.address + ); + } + } + + #[test] + fn test_script_for_address_bitcoin() { + let cases = [ + AddressScriptCase { + address: "1QJVDzdqb1VpbDK7uDeyVXy9mR27CJiyhY", + locking_script: LockingScript::P2pkh, + unlocking_script: Some(UnlockingScript::P2pkh), + script_pubkey: "76a914ff99864ce1a887e00c9c8615210d6267edd7d7a588ac", + public_key_hash: Some("ff99864ce1a887e00c9c8615210d6267edd7d7a5"), + }, + AddressScriptCase { + address: "33iFwdLuRpW1uK1RTRqsoi8rR4NpDzk66k", + locking_script: LockingScript::P2sh, + unlocking_script: None, + script_pubkey: "a914162c5ea71c0b23f5b9022ef047c4a86470a5b07087", + public_key_hash: None, + }, + AddressScriptCase { + address: "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4", + locking_script: LockingScript::P2wpkh, + unlocking_script: Some(UnlockingScript::P2wpkh), + script_pubkey: "0014751e76e8199196d454941c45d1b3a323f1433bd6", + public_key_hash: Some("751e76e8199196d454941c45d1b3a323f1433bd6"), + }, + AddressScriptCase { + address: "bc1qwqdg6squsna38e46795at95yu9atm8azzmyvckulcc7kytlcckxswvvzej", + locking_script: LockingScript::P2wsh, + unlocking_script: None, + script_pubkey: "0020701a8d401c84fb13e6baf169d59684e17abd9fa216c8cc5b9fc63d622ff8c58d", + public_key_hash: None, + }, + AddressScriptCase { + address: "bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr", + locking_script: LockingScript::P2tr, + unlocking_script: None, + script_pubkey: "5120a60869f0dbcf1dc659c9cecbaf8050135ea9e8cdc487053f1dc6880949dc684c", + public_key_hash: None, + }, + ]; + for case in &cases { + case.assert(BitcoinChain::Bitcoin); + } + } + + #[test] + fn test_script_for_address_bitcoin_cash() { + let cases = [ + AddressScriptCase { + address: "bitcoincash:qp3wjpa3tjlj042z2wv7hahsldgwhwy0rq9sywjpyy", + locking_script: LockingScript::P2pkh, + unlocking_script: Some(UnlockingScript::P2pkh), + script_pubkey: "76a91462e907b15cbf27d5425399ebf6f0fb50ebb88f1888ac", + public_key_hash: Some("62e907b15cbf27d5425399ebf6f0fb50ebb88f18"), + }, + AddressScriptCase { + address: "qp3wjpa3tjlj042z2wv7hahsldgwhwy0rq9sywjpyy", + locking_script: LockingScript::P2pkh, + unlocking_script: Some(UnlockingScript::P2pkh), + script_pubkey: "76a91462e907b15cbf27d5425399ebf6f0fb50ebb88f1888ac", + public_key_hash: Some("62e907b15cbf27d5425399ebf6f0fb50ebb88f18"), + }, + AddressScriptCase { + address: "bitcoincash:pr0662zpd7vr936d83f64u629v886aan7c77r3j5v5", + locking_script: LockingScript::P2sh, + unlocking_script: None, + script_pubkey: "a914dfad28416f9832c74d3c53aaf34a2b0e7d77b3f687", + public_key_hash: None, + }, + ]; + for case in &cases { + case.assert(BitcoinChain::BitcoinCash); + } + } + + #[test] + fn test_script_for_address_litecoin() { + let cases = [ + AddressScriptCase { + address: "LMHEFMwRsQ3nHDfb9zZqynLHxjuJ2hgyyW", + locking_script: LockingScript::P2pkh, + unlocking_script: Some(UnlockingScript::P2pkh), + script_pubkey: "76a914168ed7e47426cf09541df4979c6450b3d5a5547088ac", + public_key_hash: Some("168ed7e47426cf09541df4979c6450b3d5a55470"), + }, + AddressScriptCase { + address: "MC2JYMPVWaxqUb9qUkUbjtUwoNMo1tPaLF", + locking_script: LockingScript::P2sh, + unlocking_script: None, + script_pubkey: "a9142d3a59d2d9f68868cbd5d37afb2c0d6c921b2f3187", + public_key_hash: None, + }, + AddressScriptCase { + address: "ltc1qhzjptwpym9afcdjhs7jcz6fd0jma0l0rc0e5yr", + locking_script: LockingScript::P2wpkh, + unlocking_script: Some(UnlockingScript::P2wpkh), + script_pubkey: "0014b8a415b824d97a9c365787a581692d7cb7d7fde3", + public_key_hash: Some("b8a415b824d97a9c365787a581692d7cb7d7fde3"), + }, + ]; + for case in &cases { + case.assert(BitcoinChain::Litecoin); + } + // A Bitcoin P2SH address must not parse under Litecoin's network params. + assert!(script_for_address(BitcoinChain::Litecoin, "3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLy").is_err()); + } + + #[test] + fn test_script_for_address_doge() { + let cases = [ + AddressScriptCase { + address: "DMKhUaRmnxJXfDxyFguMnMjVdgvnNipFzt", + locking_script: LockingScript::P2pkh, + unlocking_script: Some(UnlockingScript::P2pkh), + script_pubkey: "76a914b18355f0b9c7aa20e9db204825e6275e9a40bc8988ac", + public_key_hash: Some("b18355f0b9c7aa20e9db204825e6275e9a40bc89"), + }, + AddressScriptCase { + address: "A1yb6viUzAcUWftRHT6GpnCwvhXHg4CV1x", + locking_script: LockingScript::P2sh, + unlocking_script: None, + script_pubkey: "a91468a56b88a61df17afc8a0709ec1536a51101881087", + public_key_hash: None, + }, + ]; + for case in &cases { + case.assert(BitcoinChain::Doge); + } + } + + #[test] + fn test_script_for_address_zcash() { + let cases = [ + AddressScriptCase { + address: "t1Ku2KLyndDPsR32jwnrTMd3yvi9tfFP8ML", + locking_script: LockingScript::P2pkh, + unlocking_script: Some(UnlockingScript::P2pkh), + script_pubkey: "76a9141634f5ff0b8f6603a17570436d6c12a91f4b1fed88ac", + public_key_hash: Some("1634f5ff0b8f6603a17570436d6c12a91f4b1fed"), + }, + AddressScriptCase { + address: "t3Vz22vK5z2LcKEdg16Yv4FFneEL1zg9ojd", + locking_script: LockingScript::P2sh, + unlocking_script: None, + script_pubkey: "a9147d46a730d31f97b1930d3368a967c309bd4d136a87", + public_key_hash: None, + }, + ]; + for case in &cases { + case.assert(BitcoinChain::Zcash); + } + } +} diff --git a/core/crates/gem_bitcoin/src/signer/address/script.rs b/core/crates/gem_bitcoin/src/signer/address/script.rs new file mode 100644 index 000000000..0927319bd --- /dev/null +++ b/core/crates/gem_bitcoin/src/signer/address/script.rs @@ -0,0 +1,127 @@ +use bech32::{primitives::gf32::Fe32, segwit::VERSION_0, segwit::VERSION_1}; +use bitcoin::{ + ScriptBuf, + blockdata::{opcodes::all::*, script::Builder}, +}; +use primitives::SignerError; + +use crate::hash::{HASH160_LEN, hash20}; + +const WITNESS_PROGRAM_LEN: usize = 32; +const P2PKH_HASH_OFFSET: usize = 3; +const P2WPKH_HASH_OFFSET: usize = 2; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum LockingScript { + P2pkh, + P2sh, + P2wpkh, + P2wsh, + P2tr, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum UnlockingScript { + P2pkh, + P2wpkh, +} + +#[derive(Debug, Clone)] +pub(crate) struct AddressScript { + pub(crate) script_pubkey: ScriptBuf, + pub(crate) locking_script: LockingScript, +} + +impl AddressScript { + pub(crate) fn new(script_pubkey: ScriptBuf, locking_script: LockingScript) -> Self { + Self { script_pubkey, locking_script } + } + + pub(crate) fn unlocking_script(&self) -> Option { + match (self.locking_script, self.public_key_hash()) { + (LockingScript::P2pkh, Some(_)) => Some(UnlockingScript::P2pkh), + (LockingScript::P2wpkh, Some(_)) => Some(UnlockingScript::P2wpkh), + _ => None, + } + } + + pub(crate) fn public_key_hash(&self) -> Option<[u8; HASH160_LEN]> { + let offset = match self.locking_script { + LockingScript::P2pkh if self.script_pubkey.is_p2pkh() => P2PKH_HASH_OFFSET, + LockingScript::P2wpkh if self.script_pubkey.is_p2wpkh() => P2WPKH_HASH_OFFSET, + LockingScript::P2sh | LockingScript::P2wsh | LockingScript::P2tr | LockingScript::P2pkh | LockingScript::P2wpkh => return None, + }; + self.script_pubkey.as_bytes().get(offset..offset + HASH160_LEN)?.try_into().ok() + } + + pub(super) fn from_prefixed_address(address: &str, p2pkh_versions: &[u8], p2sh_versions: &[u8], hrp: Option<&str>) -> Result { + if let Some(expected_hrp) = hrp + && let Ok((address_hrp, version, program)) = bech32::segwit::decode(address) + && address_hrp.as_str() == expected_hrp + { + return Self::from_segwit(version, &program); + } + + let payload = bs58::decode(address).with_check(None).into_vec().map_err(SignerError::from_display)?; + let Some((&version, hash)) = payload.split_first() else { + return Err(SignerError::from_display("invalid base58 address")); + }; + let hash = hash20(hash)?; + + if p2pkh_versions.contains(&version) { + Ok(Self::new(p2pkh_script(hash), LockingScript::P2pkh)) + } else if p2sh_versions.contains(&version) { + Ok(Self::new(p2sh_script(hash), LockingScript::P2sh)) + } else { + Err(SignerError::from_display("unsupported address version")) + } + } + + fn from_segwit(version: Fe32, program: &[u8]) -> Result { + let (script_pubkey, locking_script) = match (version, program.len()) { + (version, HASH160_LEN) if version == VERSION_0 => (p2wpkh_script(hash20(program)?), LockingScript::P2wpkh), + (version, WITNESS_PROGRAM_LEN) if version == VERSION_0 => (p2wsh_script(hash32(program)?), LockingScript::P2wsh), + (version, WITNESS_PROGRAM_LEN) if version == VERSION_1 => (p2tr_script(hash32(program)?), LockingScript::P2tr), + _ => return Err(SignerError::from_display("unsupported segwit address type")), + }; + + Ok(Self::new(script_pubkey, locking_script)) + } +} + +pub(crate) fn script_for_public_key_hash(unlocking_script: UnlockingScript, hash: [u8; HASH160_LEN]) -> ScriptBuf { + match unlocking_script { + UnlockingScript::P2pkh => p2pkh_script(hash), + UnlockingScript::P2wpkh => p2wpkh_script(hash), + } +} + +pub(super) fn p2pkh_script(hash: [u8; HASH160_LEN]) -> ScriptBuf { + Builder::new() + .push_opcode(OP_DUP) + .push_opcode(OP_HASH160) + .push_slice(hash) + .push_opcode(OP_EQUALVERIFY) + .push_opcode(OP_CHECKSIG) + .into_script() +} + +pub(super) fn p2sh_script(hash: [u8; HASH160_LEN]) -> ScriptBuf { + Builder::new().push_opcode(OP_HASH160).push_slice(hash).push_opcode(OP_EQUAL).into_script() +} + +fn p2wpkh_script(hash: [u8; HASH160_LEN]) -> ScriptBuf { + Builder::new().push_opcode(OP_PUSHBYTES_0).push_slice(hash).into_script() +} + +fn p2wsh_script(hash: [u8; WITNESS_PROGRAM_LEN]) -> ScriptBuf { + Builder::new().push_opcode(OP_PUSHBYTES_0).push_slice(hash).into_script() +} + +fn p2tr_script(output_key: [u8; WITNESS_PROGRAM_LEN]) -> ScriptBuf { + Builder::new().push_opcode(OP_PUSHNUM_1).push_slice(output_key).into_script() +} + +fn hash32(bytes: &[u8]) -> Result<[u8; WITNESS_PROGRAM_LEN], SignerError> { + bytes.try_into().map_err(SignerError::from_display) +} diff --git a/core/crates/gem_bitcoin/src/signer/address/zcash.rs b/core/crates/gem_bitcoin/src/signer/address/zcash.rs new file mode 100644 index 000000000..fca8d717e --- /dev/null +++ b/core/crates/gem_bitcoin/src/signer/address/zcash.rs @@ -0,0 +1,21 @@ +use primitives::SignerError; + +use super::script::{AddressScript, LockingScript, p2pkh_script, p2sh_script}; +use crate::hash::hash20; + +// Zcash mainnet transparent address version bytes: t1 for P2PKH, t3 for P2SH. +pub(crate) const TRANSPARENT_P2PKH_PREFIX: [u8; 2] = [0x1c, 0xb8]; +pub(crate) const TRANSPARENT_P2SH_PREFIX: [u8; 2] = [0x1c, 0xbd]; + +pub(super) fn script(address: &str) -> Result { + let payload = bs58::decode(address).with_check(None).into_vec().map_err(SignerError::from_display)?; + if payload.len() != 22 { + return Err(SignerError::from_display("invalid Zcash address")); + } + let hash = hash20(&payload[2..])?; + match [payload[0], payload[1]] { + TRANSPARENT_P2PKH_PREFIX => Ok(AddressScript::new(p2pkh_script(hash), LockingScript::P2pkh)), + TRANSPARENT_P2SH_PREFIX => Ok(AddressScript::new(p2sh_script(hash), LockingScript::P2sh)), + _ => Err(SignerError::from_display("unsupported Zcash address version")), + } +} diff --git a/core/crates/gem_bitcoin/src/signer/bitcoin_cash.rs b/core/crates/gem_bitcoin/src/signer/bitcoin_cash.rs new file mode 100644 index 000000000..c86eaa44b --- /dev/null +++ b/core/crates/gem_bitcoin/src/signer/bitcoin_cash.rs @@ -0,0 +1,65 @@ +use bitcoin::{ + PublicKey, Transaction, + blockdata::script::Builder, + consensus::encode::serialize, + secp256k1::{Message, Secp256k1, SecretKey, Signing}, +}; +use primitives::SignerError; + +use crate::{ + hash::double_sha256, + signer::{ + planner::SpendPlan, + transaction::{build_unsigned_transaction, der_signature, signature_push_bytes}, + }, +}; + +const SIGHASH_ALL_FORKID: u32 = 0x41; + +struct SighashComponents { + hash_prevouts: [u8; 32], + hash_sequence: [u8; 32], + hash_outputs: [u8; 32], +} + +impl SighashComponents { + fn new(transaction: &Transaction, plan: &SpendPlan) -> Self { + Self { + hash_prevouts: double_sha256(&plan.inputs.iter().flat_map(|input| serialize(&input.previous_output)).collect::>()), + hash_sequence: double_sha256(&plan.inputs.iter().flat_map(|input| input.sequence.to_le_bytes()).collect::>()), + hash_outputs: double_sha256(&transaction.output.iter().flat_map(serialize).collect::>()), + } + } +} + +pub(crate) fn sign_plan(plan: &SpendPlan, secret_key: &SecretKey, public_key: &PublicKey, secp: &Secp256k1) -> Result { + let mut transaction = build_unsigned_transaction(plan); + let components = SighashComponents::new(&transaction, plan); + for (index, _) in plan.inputs.iter().enumerate() { + let sighash = signature_hash(&transaction, plan, &components, index)?; + let signature = der_signature(secp, secret_key, Message::from_digest(sighash), SIGHASH_ALL_FORKID as u8); + transaction.input[index].script_sig = Builder::new().push_slice(signature_push_bytes(signature)?).push_key(public_key).into_script(); + } + Ok(transaction) +} + +fn signature_hash(transaction: &Transaction, plan: &SpendPlan, components: &SighashComponents, input_index: usize) -> Result<[u8; 32], SignerError> { + let input = plan + .inputs + .get(input_index) + .ok_or_else(|| SignerError::signing_error("Bitcoin Cash input index out of bounds"))?; + + let mut preimage = Vec::new(); + preimage.extend_from_slice(&transaction.version.0.to_le_bytes()); + preimage.extend_from_slice(&components.hash_prevouts); + preimage.extend_from_slice(&components.hash_sequence); + preimage.extend_from_slice(&serialize(&input.previous_output)); + preimage.extend_from_slice(&serialize(input.script_pubkey.as_script())); + preimage.extend_from_slice(&input.value.to_sat().to_le_bytes()); + preimage.extend_from_slice(&input.sequence.to_le_bytes()); + preimage.extend_from_slice(&components.hash_outputs); + preimage.extend_from_slice(&transaction.lock_time.to_consensus_u32().to_le_bytes()); + preimage.extend_from_slice(&SIGHASH_ALL_FORKID.to_le_bytes()); + + Ok(double_sha256(&preimage)) +} diff --git a/core/crates/gem_bitcoin/src/signer/chain_signer.rs b/core/crates/gem_bitcoin/src/signer/chain_signer.rs new file mode 100644 index 000000000..4eda40400 --- /dev/null +++ b/core/crates/gem_bitcoin/src/signer/chain_signer.rs @@ -0,0 +1,35 @@ +use primitives::{BitcoinChain, ChainSigner, SignerError, SignerInput}; + +use crate::signer::{ + planner::{SpendPlan, SpendRequest, UtxoPlanner}, + transaction::sign_plan, +}; + +pub struct BitcoinChainSigner { + chain: BitcoinChain, +} + +impl BitcoinChainSigner { + pub fn new(chain: BitcoinChain) -> Self { + Self { chain } + } + + fn sign_request(&self, request: SpendRequest, private_key: &[u8], zcash_branch_id: Option) -> Result { + let plan: SpendPlan = UtxoPlanner::plan(request)?; + sign_plan(self.chain, &plan, private_key, zcash_branch_id) + } +} + +impl ChainSigner for BitcoinChainSigner { + fn sign_transfer(&self, input: &SignerInput, private_key: &[u8]) -> Result { + self.sign_request(SpendRequest::transfer(self.chain, input)?, private_key, input.metadata.get_zcash_branch_id()) + } + + fn sign_swap(&self, input: &SignerInput, private_key: &[u8]) -> Result, SignerError> { + Ok(vec![self.sign_request( + SpendRequest::swap(self.chain, input)?, + private_key, + input.metadata.get_zcash_branch_id(), + )?]) + } +} diff --git a/core/crates/gem_bitcoin/src/signer/encoding.rs b/core/crates/gem_bitcoin/src/signer/encoding.rs index d60a813fc..4709f10ac 100644 --- a/core/crates/gem_bitcoin/src/signer/encoding.rs +++ b/core/crates/gem_bitcoin/src/signer/encoding.rs @@ -13,24 +13,32 @@ pub fn encode_varint(n: usize) -> Vec { } } +pub(crate) fn varint_len(value: usize) -> usize { + match value { + 0..=0xfc => 1, + 0xfd..=0xffff => 3, + 0x1_0000..=0xffff_ffff => 5, + _ => 9, + } +} + #[cfg(test)] mod tests { use super::*; #[test] - fn test_encode_varint_small() { + fn test_encode_varint() { assert_eq!(encode_varint(0), vec![0]); assert_eq!(encode_varint(252), vec![252]); - } - - #[test] - fn test_encode_varint_medium() { assert_eq!(encode_varint(253), vec![0xfd, 253, 0]); assert_eq!(encode_varint(0xffff), vec![0xfd, 0xff, 0xff]); - } - - #[test] - fn test_encode_varint_large() { assert_eq!(encode_varint(0x10000), vec![0xfe, 0, 0, 1, 0]); + assert_eq!(varint_len(0), 1); + assert_eq!(varint_len(0xfc), 1); + assert_eq!(varint_len(0xfd), 3); + assert_eq!(varint_len(0xffff), 3); + assert_eq!(varint_len(0x1_0000), 5); + assert_eq!(varint_len(0xffff_ffff), 5); + assert_eq!(varint_len(0x1_0000_0000), 9); } } diff --git a/core/crates/gem_bitcoin/src/signer/mod.rs b/core/crates/gem_bitcoin/src/signer/mod.rs index 56171d6cf..abc8b9794 100644 --- a/core/crates/gem_bitcoin/src/signer/mod.rs +++ b/core/crates/gem_bitcoin/src/signer/mod.rs @@ -1,6 +1,276 @@ +pub(crate) mod address; +mod bitcoin_cash; +mod chain_signer; mod encoding; +mod personalization; +mod planner; mod signature; +mod transaction; mod types; +mod zcash; +#[cfg(feature = "rpc")] +use std::collections::HashMap; + +#[cfg(feature = "rpc")] +use num_bigint::BigInt; +#[cfg(feature = "rpc")] +use primitives::{BitcoinChain, SignerError, SignerInput, TransactionFee, TransactionInputType, TransactionLoadInput}; + +pub use chain_signer::BitcoinChainSigner; +#[cfg(test)] +pub(crate) use planner::PlanInput; pub use signature::sign_personal; pub use types::{BitcoinSignDataResponse, BitcoinSignMessageData}; + +#[cfg(feature = "rpc")] +pub(crate) fn estimate_transaction_fee(chain: BitcoinChain, input: &TransactionLoadInput) -> Result { + let signer_input = SignerInput::new(input.clone(), input.default_fee()); + let request = match &input.input_type { + TransactionInputType::Transfer(_) => planner::SpendRequest::transfer(chain, &signer_input)?, + TransactionInputType::Swap(_, _, _) => planner::SpendRequest::swap(chain, &signer_input)?, + _ => return SignerError::invalid_input_err("unsupported Bitcoin transaction type"), + }; + let plan = planner::UtxoPlanner::plan(request)?; + + Ok(TransactionFee { + fee: BigInt::from(plan.fee), + gas_price_type: input.gas_price.clone(), + gas_limit: BigInt::from(1u8), + options: HashMap::new(), + }) +} + +#[cfg(test)] +mod tests { + use bitcoin::consensus::encode::deserialize; + use primitives::{BitcoinChain, ChainSigner, SignerInput, SwapProvider, TransactionLoadMetadata, decode_hex}; + + use super::{BitcoinChainSigner, address::script_for_address}; + use crate::testkit::signer_mock::{ + TEST_PRIVATE_KEY, mock_contract_swap_input, mock_contract_swap_input_with_provider, mock_funded_transfer_input, mock_p2wpkh_contract_swap_input, + mock_p2wpkh_transfer_input, mock_transfer_input, mock_transfer_swap_input, + }; + + const CHAINFLIP_NULLDATA_HEX: &str = "deadbeef001122"; + + fn sign_transfer(chain: BitcoinChain) -> String { + BitcoinChainSigner::new(chain).sign_transfer(&mock_transfer_input(chain), &TEST_PRIVATE_KEY).unwrap() + } + + fn assert_op_return_payload(script: &bitcoin::ScriptBuf, payload: &[u8]) { + let bytes = script.as_bytes(); + assert_eq!(bytes[0], 0x6a); + assert_eq!(bytes[1] as usize, payload.len()); + assert_eq!(&bytes[2..], payload); + } + + fn sign_contract_swap(input: &SignerInput) -> bitcoin::Transaction { + let raw = BitcoinChainSigner::new(BitcoinChain::Bitcoin).sign_swap(input, &TEST_PRIVATE_KEY).unwrap().remove(0); + deserialize(&hex::decode(raw).unwrap()).unwrap() + } + + fn sender_script(input: &SignerInput) -> bitcoin::ScriptBuf { + script_for_address(BitcoinChain::Bitcoin, &input.sender_address).unwrap().script_pubkey + } + + #[test] + fn test_sign_transfer_bitcoin() { + let raw = sign_transfer(BitcoinChain::Bitcoin); + let transaction: bitcoin::Transaction = deserialize(&hex::decode(&raw).unwrap()).unwrap(); + let script = transaction.input[0].script_sig.as_bytes(); + let signature_len = script[0] as usize; + assert_eq!(transaction.input.len(), 1); + assert_eq!(transaction.output[0].value.to_sat(), 10_000); + assert_eq!(script[signature_len], 0x01); + } + + #[test] + fn test_sign_transfer_doge() { + let input = mock_funded_transfer_input(BitcoinChain::Doge); + let raw = BitcoinChainSigner::new(BitcoinChain::Doge).sign_transfer(&input, &TEST_PRIVATE_KEY).unwrap(); + let transaction: bitcoin::Transaction = deserialize(&hex::decode(raw).unwrap()).unwrap(); + let script = transaction.input[0].script_sig.as_bytes(); + let signature_len = script[0] as usize; + assert_eq!(transaction.input.len(), 1); + assert_eq!(transaction.output[0].value.to_sat(), 10_000); + assert_eq!(script[signature_len], 0x01); + } + + #[test] + fn test_sign_transfer_bitcoin_cash() { + let raw = sign_transfer(BitcoinChain::BitcoinCash); + let transaction: bitcoin::Transaction = deserialize(&hex::decode(raw).unwrap()).unwrap(); + let script = transaction.input[0].script_sig.as_bytes(); + let signature_len = script[0] as usize; + assert_eq!(transaction.input.len(), 1); + assert_eq!(transaction.output[0].value.to_sat(), 10_000); + assert_eq!(script[signature_len], 0x41); + } + + #[test] + fn test_signed_tx_is_rbf_signaled() { + let raw = BitcoinChainSigner::new(BitcoinChain::Bitcoin) + .sign_transfer(&mock_transfer_input(BitcoinChain::Bitcoin), &TEST_PRIVATE_KEY) + .unwrap(); + let transaction: bitcoin::Transaction = deserialize(&hex::decode(&raw).unwrap()).unwrap(); + + assert_eq!( + raw, + "02000000010100000000000000000000000000000000000000000000000000000000000000000000006b483045022100ef21c70ee59cd7ef09f5d12252ed3c1c4fa971b33740cf2c29b90ab15fc3e5ea02205761ce83d839043d38341344c499cd13cb2ed4e03ebd88c6cf38fc84415b1e290121031b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078ffdffffff0210270000000000001976a91479b000887626b294a914501a4cd226b58b23598388ac5e9b0000000000001976a91479b000887626b294a914501a4cd226b58b23598388ac00000000" + ); + for input in &transaction.input { + assert_eq!(input.sequence.0, 0xffff_fffd); + } + } + + #[test] + fn test_sign_transfer_zcash() { + let raw = sign_transfer(BitcoinChain::Zcash); + let zcash_bytes = hex::decode(raw).unwrap(); + assert_eq!(&zcash_bytes[..20], hex::decode("050000800a27a726f04dec4d0000000000000000").unwrap().as_slice()); + } + + #[test] + fn test_sign_transfer_vectors() { + // Test vectors are checked against BitGoJS. + let cases = [ + ( + "bitcoin_p2pkh", + BitcoinChain::Bitcoin, + mock_transfer_input(BitcoinChain::Bitcoin), + "02000000010100000000000000000000000000000000000000000000000000000000000000000000006b483045022100ef21c70ee59cd7ef09f5d12252ed3c1c4fa971b33740cf2c29b90ab15fc3e5ea02205761ce83d839043d38341344c499cd13cb2ed4e03ebd88c6cf38fc84415b1e290121031b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078ffdffffff0210270000000000001976a91479b000887626b294a914501a4cd226b58b23598388ac5e9b0000000000001976a91479b000887626b294a914501a4cd226b58b23598388ac00000000", + ), + ( + "bitcoin_p2wpkh", + BitcoinChain::Bitcoin, + mock_p2wpkh_transfer_input(), + "0200000000010101000000000000000000000000000000000000000000000000000000000000000000000000fdffffff02102700000000000016001479b000887626b294a914501a4cd226b58b235983b39b00000000000016001479b000887626b294a914501a4cd226b58b235983024730440220093e657368a7f4cbb66153627854a437a4119fa2b43c8a172bef796b5a59054602201b55591497a8bd0be56dd1241dd22c595e3ad65fe0b03dc763139b1ce2c0b86b0121031b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f00000000", + ), + ( + "litecoin", + BitcoinChain::Litecoin, + mock_transfer_input(BitcoinChain::Litecoin), + "02000000010100000000000000000000000000000000000000000000000000000000000000000000006a4730440220336a874568fc8f0817c10d804a0b8f9f3d595d280d3b2fa9a4de465ee56d273202200e9ad81a08b28a095b62695fb156d3a7e7b5044aa22dbb63f510028fa80f1a430121031b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078ffdffffff0210270000000000001976a914020202020202020202020202020202020202020288acd6970000000000001976a91479b000887626b294a914501a4cd226b58b23598388ac00000000", + ), + ( + "doge", + BitcoinChain::Doge, + mock_funded_transfer_input(BitcoinChain::Doge), + "02000000010100000000000000000000000000000000000000000000000000000000000000000000006b483045022100dc2bcc8259c3f355ff471cc8a44633b495d4febb8e90b30ee847c37a791476ba022079cff47cb5afcf61dc18a958ce97f97748bdde598a8ed442632f1703d44710290121031b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078ffdffffff0210270000000000001976a914020202020202020202020202020202020202020288ac2047f205000000001976a91479b000887626b294a914501a4cd226b58b23598388ac00000000", + ), + ( + "bitcoin_cash", + BitcoinChain::BitcoinCash, + mock_transfer_input(BitcoinChain::BitcoinCash), + "02000000010100000000000000000000000000000000000000000000000000000000000000000000006a47304402202454d9566a573c0d47f6c2ee32e29fe946da4d859cf49d046b737fbc497070a6022077f20c9841368e646948066a0583b4f6aabeef0c2baff4c9e8733c1b3d98f5444121031b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078ffdffffff0210270000000000001976a914020202020202020202020202020202020202020288acd6970000000000001976a91479b000887626b294a914501a4cd226b58b23598388ac00000000", + ), + ( + "zcash", + BitcoinChain::Zcash, + mock_transfer_input(BitcoinChain::Zcash), + "050000800a27a726f04dec4d0000000000000000010100000000000000000000000000000000000000000000000000000000000000000000006a473044022004354fd389558909b1ccfb05dd2f3c324423efcad18eb50a42cb65ebdd17513d02207dac81ff91f4108b4017329e55971c2bd6a213cf925886b95513519bd806fd9a0121031b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078fffffffff0210270000000000001976a914030303030303030303030303030303030303030388ac30750000000000001976a91479b000887626b294a914501a4cd226b58b23598388ac000000", + ), + ]; + + for (name, chain, input, expected) in cases { + let raw = BitcoinChainSigner::new(chain).sign_transfer(&input, &TEST_PRIVATE_KEY).unwrap(); + assert_eq!(raw, expected, "{name}"); + } + } + + #[test] + fn test_sign_swap_memo_op_return() { + let memo = "=:s:0xEe7E9CcFb529f2c1Cc02C0Aea8aCed7Ec7e98B5e:0/1/0:g1:50"; + let input = mock_transfer_swap_input(BitcoinChain::Doge, memo); + let raw = BitcoinChainSigner::new(BitcoinChain::Doge).sign_swap(&input, &TEST_PRIVATE_KEY).unwrap().remove(0); + let transaction: bitcoin::Transaction = deserialize(&hex::decode(raw).unwrap()).unwrap(); + let memo_output = transaction.output.iter().find(|output| output.script_pubkey.is_op_return()).unwrap(); + assert_eq!(memo_output.value.to_sat(), 0); + assert_op_return_payload(&memo_output.script_pubkey, memo.as_bytes()); + } + + #[test] + fn test_sign_bitcoin_thorchain_transfer_memo_op_return() { + let memo = "=:ETH.ETH:0x858734a6353C9921a78fB3c937c8E20Ba6f36902:0/1/0"; + let mut input = mock_transfer_input(BitcoinChain::Bitcoin); + input.input.memo = Some(memo.to_string()); + + let raw = BitcoinChainSigner::new(BitcoinChain::Bitcoin).sign_transfer(&input, &TEST_PRIVATE_KEY).unwrap(); + let transaction: bitcoin::Transaction = deserialize(&hex::decode(raw).unwrap()).unwrap(); + + assert_eq!(transaction.output.len(), 3); + assert_eq!(transaction.output[0].value.to_sat(), 10_000); + assert_eq!(transaction.output[1].value.to_sat(), 0); + assert_op_return_payload(&transaction.output[1].script_pubkey, memo.as_bytes()); + } + + #[test] + fn test_sign_chainflip_bitcoin_max_intent_produces_change_for_refund() { + let nulldata = decode_hex(CHAINFLIP_NULLDATA_HEX).unwrap(); + let input = mock_p2wpkh_contract_swap_input(CHAINFLIP_NULLDATA_HEX, true); + let refund_script = sender_script(&input); + let transaction = sign_contract_swap(&input); + + assert_eq!(transaction.output.len(), 3); + assert_eq!(transaction.output[0].value.to_sat(), 10_000); + assert_eq!(transaction.output[1].value.to_sat(), 0); + assert_op_return_payload(&transaction.output[1].script_pubkey, &nulldata); + assert_eq!(transaction.output[2].value.to_sat(), 99_989_838); + assert_eq!(transaction.output[2].script_pubkey, refund_script); + } + + #[test] + fn test_sign_chainflip_bitcoin_exact_swap_with_change() { + let nulldata = decode_hex(CHAINFLIP_NULLDATA_HEX).unwrap(); + let input = mock_contract_swap_input(BitcoinChain::Bitcoin, CHAINFLIP_NULLDATA_HEX, false); + let transaction = sign_contract_swap(&input); + + assert_eq!(transaction.output.len(), 3); + assert_eq!(transaction.output[0].value.to_sat(), 10_000); + assert_eq!(transaction.output[1].value.to_sat(), 0); + assert_op_return_payload(&transaction.output[1].script_pubkey, &nulldata); + assert_eq!(transaction.output[2].value.to_sat(), 99_989_756); + } + + #[test] + fn test_chainflip_contract_swap_always_has_refund_output() { + let nulldata = decode_hex(CHAINFLIP_NULLDATA_HEX).unwrap(); + for use_max_amount in [true, false] { + let input = mock_contract_swap_input(BitcoinChain::Bitcoin, CHAINFLIP_NULLDATA_HEX, use_max_amount); + let refund_script = sender_script(&input); + let transaction = sign_contract_swap(&input); + + assert_eq!(transaction.output.len(), 3, "{use_max_amount}"); + assert_eq!(transaction.output[1].value.to_sat(), 0, "{use_max_amount}"); + assert_op_return_payload(&transaction.output[1].script_pubkey, &nulldata); + assert_eq!(transaction.output[2].script_pubkey, refund_script, "{use_max_amount}"); + } + } + + #[test] + fn test_chainflip_contract_swap_fails_without_refund_output_budget() { + let mut input = mock_contract_swap_input(BitcoinChain::Bitcoin, CHAINFLIP_NULLDATA_HEX, false); + let TransactionLoadMetadata::Bitcoin { utxos } = &mut input.input.metadata else { + unreachable!() + }; + // 10_000 payment + 244 fee would leave no non-dust refund output. + utxos[0].value = "10244".to_string(); + + let error = BitcoinChainSigner::new(BitcoinChain::Bitcoin).sign_swap(&input, &TEST_PRIVATE_KEY).unwrap_err().to_string(); + + assert!(error.contains("insufficient balance")); + } + + #[test] + fn test_non_chainflip_contract_swap_honors_max_flag() { + let nulldata = decode_hex(CHAINFLIP_NULLDATA_HEX).unwrap(); + let input = mock_contract_swap_input_with_provider(BitcoinChain::Bitcoin, CHAINFLIP_NULLDATA_HEX, true, SwapProvider::Thorchain); + let transaction = sign_contract_swap(&input); + + assert_eq!(transaction.output.len(), 2); + assert_eq!(transaction.output[0].value.to_sat(), 100_000_000 - 210); + assert_eq!(transaction.output[1].value.to_sat(), 0); + assert_op_return_payload(&transaction.output[1].script_pubkey, &nulldata); + } +} diff --git a/core/crates/gem_bitcoin/src/signer/personalization.rs b/core/crates/gem_bitcoin/src/signer/personalization.rs new file mode 100644 index 000000000..d3febc72b --- /dev/null +++ b/core/crates/gem_bitcoin/src/signer/personalization.rs @@ -0,0 +1,19 @@ +//! BLAKE2b-256 personalization tags for Zcash ZIP-244 transparent transaction signing. +//! Each tag is 16 bytes. See https://zips.z.cash/zip-0244. + +// Per-component txid digests. +pub(super) const ZCASH_TXID_HEADERS_HASH_PERSONALIZATION: &[u8; 16] = b"ZTxIdHeadersHash"; +pub(super) const ZCASH_TXID_PREVOUTS_HASH_PERSONALIZATION: &[u8; 16] = b"ZTxIdPrevoutHash"; +pub(super) const ZCASH_TXID_SEQUENCES_HASH_PERSONALIZATION: &[u8; 16] = b"ZTxIdSequencHash"; +pub(super) const ZCASH_TXID_OUTPUTS_HASH_PERSONALIZATION: &[u8; 16] = b"ZTxIdOutputsHash"; +pub(super) const ZCASH_TXID_SAPLING_HASH_PERSONALIZATION: &[u8; 16] = b"ZTxIdSaplingHash"; +pub(super) const ZCASH_TXID_ORCHARD_HASH_PERSONALIZATION: &[u8; 16] = b"ZTxIdOrchardHash"; + +// Transparent sighash digests. +pub(super) const ZCASH_TXID_TRANSPARENT_HASH_PERSONALIZATION: &[u8; 16] = b"ZTxIdTranspaHash"; +pub(super) const ZCASH_TRANSPARENT_AMOUNTS_HASH_PERSONALIZATION: &[u8; 16] = b"ZTxTrAmountsHash"; +pub(super) const ZCASH_TRANSPARENT_SCRIPTS_HASH_PERSONALIZATION: &[u8; 16] = b"ZTxTrScriptsHash"; +pub(super) const ZCASH_TXIN_HASH_PERSONALIZATION: &[u8; 16] = b"Zcash___TxInHash"; + +// 12-byte prefix; the final 4 bytes are the little-endian consensus branch id. +pub(super) const ZCASH_TX_HASH_PERSONALIZATION_PREFIX: &[u8; 12] = b"ZcashTxHash_"; diff --git a/core/crates/gem_bitcoin/src/signer/planner/fee.rs b/core/crates/gem_bitcoin/src/signer/planner/fee.rs new file mode 100644 index 000000000..1de65e69e --- /dev/null +++ b/core/crates/gem_bitcoin/src/signer/planner/fee.rs @@ -0,0 +1,90 @@ +use primitives::BitcoinChain; + +use super::{PlanInput, PlanOutput}; +use crate::signer::{address::UnlockingScript, encoding::varint_len}; + +const WITNESS_SCALE_FACTOR: u128 = 4; +const TRANSACTION_FIXED_BYTES: u128 = 8; +const SEGWIT_MARKER_FLAG_WEIGHT: u128 = 2; +const P2PKH_INPUT_BYTES: u128 = 148; +const P2WPKH_INPUT_BASE_BYTES: u128 = 41; +const P2WPKH_INPUT_WITNESS_BYTES: u128 = 108; + +const ZCASH_MARGINAL_FEE: u128 = 5_000; +const ZCASH_GRACE_ACTIONS: u128 = 2; +const ZCASH_P2PKH_INPUT_SIZE: u128 = 150; +const ZCASH_P2PKH_OUTPUT_SIZE: u128 = 34; + +pub(super) fn estimate_fee(chain: BitcoinChain, inputs: &[PlanInput], outputs: &[PlanOutput], fee_rate: u64) -> u64 { + let fee = match chain { + BitcoinChain::Zcash => estimate_zcash_fee(inputs, outputs), + BitcoinChain::Bitcoin | BitcoinChain::BitcoinCash | BitcoinChain::Litecoin | BitcoinChain::Doge => estimate_bitcoin_fee(inputs, outputs, fee_rate), + }; + fee as u64 +} + +fn estimate_bitcoin_fee(inputs: &[PlanInput], outputs: &[PlanOutput], fee_rate: u64) -> u128 { + let has_witness = inputs.iter().any(|input| match input.unlocking_script { + UnlockingScript::P2wpkh => true, + UnlockingScript::P2pkh => false, + }); + let input_weight: u128 = inputs.iter().map(input_weight).sum(); + let output_weight: u128 = outputs.iter().map(|output| output.serialized_len() as u128 * WITNESS_SCALE_FACTOR).sum(); + let weight = transaction_base_weight(inputs.len(), outputs.len(), has_witness) + input_weight + output_weight; + weight.div_ceil(WITNESS_SCALE_FACTOR) * fee_rate as u128 +} + +fn transaction_base_weight(input_count: usize, output_count: usize, has_witness: bool) -> u128 { + let base_bytes = TRANSACTION_FIXED_BYTES + varint_len(input_count) as u128 + varint_len(output_count) as u128; + let witness_weight = if has_witness { SEGWIT_MARKER_FLAG_WEIGHT } else { 0 }; + base_bytes * WITNESS_SCALE_FACTOR + witness_weight +} + +fn input_weight(input: &PlanInput) -> u128 { + match input.unlocking_script { + UnlockingScript::P2pkh => P2PKH_INPUT_BYTES * WITNESS_SCALE_FACTOR, + UnlockingScript::P2wpkh => P2WPKH_INPUT_BASE_BYTES * WITNESS_SCALE_FACTOR + P2WPKH_INPUT_WITNESS_BYTES, + } +} + +fn estimate_zcash_fee(inputs: &[PlanInput], outputs: &[PlanOutput]) -> u128 { + let input_total_size = inputs.len() as u128 * ZCASH_P2PKH_INPUT_SIZE; + let output_total_size: u128 = outputs.iter().map(|output| output.serialized_len() as u128).sum(); + let input_actions = input_total_size.div_ceil(ZCASH_P2PKH_INPUT_SIZE); + let output_actions = output_total_size.div_ceil(ZCASH_P2PKH_OUTPUT_SIZE); + let logical_actions = input_actions.max(output_actions); + ZCASH_MARGINAL_FEE * ZCASH_GRACE_ACTIONS.max(logical_actions) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{signer::address::script_for_public_key_hash, testkit::planner_mock::op_return_script}; + + #[test] + fn test_estimate_fee() { + let legacy_inputs = vec![PlanInput::mock_with_unlocking_script(UnlockingScript::P2pkh)]; + let legacy_outputs = vec![ + PlanOutput::new(10_000, script_for_public_key_hash(UnlockingScript::P2pkh, [0u8; 20])), + PlanOutput::new(20_000, script_for_public_key_hash(UnlockingScript::P2pkh, [0u8; 20])), + ]; + assert_eq!(estimate_fee(BitcoinChain::Bitcoin, &legacy_inputs, &legacy_outputs, 2), 452); + + let segwit_inputs = vec![PlanInput::mock_with_unlocking_script(UnlockingScript::P2wpkh)]; + let segwit_outputs = vec![PlanOutput::new(10_000, script_for_public_key_hash(UnlockingScript::P2wpkh, [0u8; 20]))]; + assert_eq!(estimate_fee(BitcoinChain::Bitcoin, &segwit_inputs, &segwit_outputs, 3), 330); + + let zcash_outputs = vec![PlanOutput::new(10_000, script_for_public_key_hash(UnlockingScript::P2pkh, [0u8; 20]))]; + assert_eq!(estimate_fee(BitcoinChain::Zcash, &legacy_inputs, &zcash_outputs, 100), 10_000); + + let zcash_inputs = vec![ + PlanInput::mock_with_unlocking_script(UnlockingScript::P2pkh), + PlanInput::mock_with_unlocking_script(UnlockingScript::P2pkh), + PlanInput::mock_with_unlocking_script(UnlockingScript::P2pkh), + ]; + assert_eq!(estimate_fee(BitcoinChain::Zcash, &zcash_inputs, &zcash_outputs, 100), 15_000); + + let zcash_large_output = vec![PlanOutput::new(0, op_return_script(80))]; + assert_eq!(estimate_fee(BitcoinChain::Zcash, &legacy_inputs, &zcash_large_output, 100), 15_000); + } +} diff --git a/core/crates/gem_bitcoin/src/signer/planner/inputs.rs b/core/crates/gem_bitcoin/src/signer/planner/inputs.rs new file mode 100644 index 000000000..1f7544dfe --- /dev/null +++ b/core/crates/gem_bitcoin/src/signer/planner/inputs.rs @@ -0,0 +1,95 @@ +use std::str::FromStr; + +use bitcoin::{Amount, OutPoint, Txid}; +use primitives::{BitcoinChain, SignerError, UTXO}; + +use super::PlanInput; +use crate::signer::address::script_for_address; + +const FINAL_SEQUENCE: u32 = 0xffff_ffff; +const RBF_SEQUENCE: u32 = 0xffff_fffd; + +pub(super) fn spendable_inputs(chain: BitcoinChain, sender_address: &str, utxos: Vec) -> Result, SignerError> { + let message = |reason: &str| format!("{} {reason}", chain.get_chain()); + + let sender = script_for_address(chain, sender_address)?; + let sender_hash = sender + .unlocking_script() + .zip(sender.public_key_hash()) + .map(|(_, public_key_hash)| public_key_hash) + .ok_or_else(|| SignerError::invalid_input(message("sender address type is unsupported")))?; + // RBF is signaled on every BTC-family chain; Zcash has no RBF and keeps the final sequence. + let sequence = match chain { + BitcoinChain::Bitcoin | BitcoinChain::BitcoinCash | BitcoinChain::Litecoin | BitcoinChain::Doge => RBF_SEQUENCE, + BitcoinChain::Zcash => FINAL_SEQUENCE, + }; + let mut inputs = Vec::with_capacity(utxos.len()); + + for utxo in utxos { + let address = script_for_address(chain, &utxo.address)?; + let (unlocking_script, public_key_hash) = address + .unlocking_script() + .zip(address.public_key_hash()) + .ok_or_else(|| SignerError::invalid_input(message("UTXO address type is unsupported")))?; + if public_key_hash != sender_hash { + return SignerError::invalid_input_err(message("UTXO address does not match sender address")); + } + let value = utxo.value_u64().map_err(SignerError::from_display)?; + if value == 0 { + return SignerError::invalid_input_err(message("UTXO amount is zero")); + } + let vout = u32::try_from(utxo.vout).map_err(|_| SignerError::invalid_input(message("UTXO output index is invalid")))?; + let txid = Txid::from_str(&utxo.transaction_id).map_err(|_| SignerError::invalid_input(message("UTXO transaction id is invalid")))?; + inputs.push(PlanInput { + previous_output: OutPoint::new(txid, vout), + value: Amount::from_sat(value), + script_pubkey: address.script_pubkey, + unlocking_script, + sequence, + }); + } + + Ok(inputs) +} + +#[cfg(test)] +mod tests { + use crate::{ + signer::address::UnlockingScript, + testkit::{ + address_mock::{TEST_BITCOIN_P2WPKH_ADDRESS, TEST_BITCOIN_P2WPKH_HASH, prefixed_address}, + signer_mock::mock_utxo_with_address, + }, + }; + + use super::*; + + const TAPROOT_ADDRESS: &str = "bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr"; + + #[test] + fn test_spendable_inputs_address_type_validation() { + let legacy_address = prefixed_address(&[0], TEST_BITCOIN_P2WPKH_HASH); + let inputs = spendable_inputs( + BitcoinChain::Bitcoin, + TEST_BITCOIN_P2WPKH_ADDRESS, + vec![mock_utxo_with_address(&legacy_address), mock_utxo_with_address(TEST_BITCOIN_P2WPKH_ADDRESS)], + ) + .unwrap(); + assert_eq!(inputs[0].unlocking_script, UnlockingScript::P2pkh); + assert_eq!(inputs[1].unlocking_script, UnlockingScript::P2wpkh); + + let different_legacy_address = prefixed_address(&[0], [9u8; 20]); + assert_eq!( + spendable_inputs(BitcoinChain::Bitcoin, TEST_BITCOIN_P2WPKH_ADDRESS, vec![mock_utxo_with_address(&different_legacy_address)],).err(), + Some(SignerError::invalid_input("bitcoin UTXO address does not match sender address")), + ); + assert_eq!( + spendable_inputs(BitcoinChain::Bitcoin, TEST_BITCOIN_P2WPKH_ADDRESS, vec![mock_utxo_with_address(TAPROOT_ADDRESS)]).err(), + Some(SignerError::invalid_input("bitcoin UTXO address type is unsupported")), + ); + assert_eq!( + spendable_inputs(BitcoinChain::Bitcoin, TAPROOT_ADDRESS, vec![mock_utxo_with_address(TEST_BITCOIN_P2WPKH_ADDRESS)]).err(), + Some(SignerError::invalid_input("bitcoin sender address type is unsupported")), + ); + } +} diff --git a/core/crates/gem_bitcoin/src/signer/planner/mod.rs b/core/crates/gem_bitcoin/src/signer/planner/mod.rs new file mode 100644 index 000000000..b3bcd0a79 --- /dev/null +++ b/core/crates/gem_bitcoin/src/signer/planner/mod.rs @@ -0,0 +1,12 @@ +mod fee; +mod inputs; +mod outputs; +mod request; +mod spend; +mod types; + +pub(crate) use self::{ + request::SpendRequest, + spend::UtxoPlanner, + types::{PlanInput, PlanOutput, SpendPlan}, +}; diff --git a/core/crates/gem_bitcoin/src/signer/planner/outputs.rs b/core/crates/gem_bitcoin/src/signer/planner/outputs.rs new file mode 100644 index 000000000..a8798d75b --- /dev/null +++ b/core/crates/gem_bitcoin/src/signer/planner/outputs.rs @@ -0,0 +1,35 @@ +use bitcoin::{ + ScriptBuf, + blockdata::{opcodes::all::OP_RETURN, script::Builder}, + script::PushBytesBuf, +}; +use primitives::SignerError; + +use super::PlanOutput; + +const MAX_OP_RETURN_BYTES: usize = 80; + +// OP_RETURN is placed at output index 1 (after the destination): required by Chainflip's +// vault-swap scanner, accepted by Thorchain which scans all outputs. +pub(super) fn spend_outputs(amount: u64, payment_script: ScriptBuf, memo_output: Option) -> Vec { + let mut outputs = vec![PlanOutput::new(amount, payment_script)]; + if let Some(output) = memo_output { + outputs.push(output); + } + outputs +} + +pub(super) fn op_return_output(data: &[u8]) -> Result { + if data.len() > MAX_OP_RETURN_BYTES { + return SignerError::invalid_input_err("Bitcoin memo is too large"); + } + let push = PushBytesBuf::try_from(data.to_vec()).map_err(SignerError::from_display)?; + Ok(PlanOutput::new(0, Builder::new().push_opcode(OP_RETURN).push_slice(push).into_script())) +} + +pub(super) fn dust_threshold(script_pubkey: &ScriptBuf) -> u64 { + if script_pubkey.is_op_return() { + return 0; + } + script_pubkey.minimal_non_dust().to_sat() +} diff --git a/core/crates/gem_bitcoin/src/signer/planner/request.rs b/core/crates/gem_bitcoin/src/signer/planner/request.rs new file mode 100644 index 000000000..c433cc024 --- /dev/null +++ b/core/crates/gem_bitcoin/src/signer/planner/request.rs @@ -0,0 +1,81 @@ +use primitives::{Asset, BitcoinChain, SignerError, SignerInput, SwapProvider, UTXO, decode_hex, swap::SwapQuoteDataType}; + +#[derive(Debug, Clone)] +pub(crate) struct SpendRequest { + pub(crate) chain: BitcoinChain, + pub(crate) sender_address: String, + pub(crate) destination_address: String, + pub(crate) amount: u64, + pub(crate) is_max: bool, + pub(crate) force_change_output: bool, + pub(crate) fee_rate: u64, + pub(crate) memo: Option>, + pub(crate) utxos: Vec, +} + +impl SpendRequest { + pub(crate) fn transfer(chain: BitcoinChain, input: &SignerInput) -> Result { + validate_native_chain_asset(chain, input.input_type.get_asset(), "unsupported Bitcoin asset transfer")?; + + Ok(Self { + chain, + sender_address: input.sender_address.clone(), + destination_address: input.destination_address.clone(), + amount: input.value_as_u64()?, + is_max: input.is_max_value, + force_change_output: false, + fee_rate: spend_fee_rate(chain, input)?, + memo: input.get_memo().map(|memo| memo.as_bytes().to_vec()), + utxos: input.metadata.get_utxos()?, + }) + } + + pub(crate) fn swap(chain: BitcoinChain, input: &SignerInput) -> Result { + let swap = input + .input_type + .get_swap_data() + .map_err(|_| SignerError::invalid_input("unsupported Bitcoin transaction type"))?; + validate_native_chain_asset(chain, input.input_type.get_asset(), "unsupported Bitcoin swap asset")?; + let memo = match &swap.data.data_type { + SwapQuoteDataType::Transfer => swap.data.memo.as_ref().map(|memo| memo.as_bytes().to_vec()), + SwapQuoteDataType::Contract => Some(decode_hex(&swap.data.data)?), + }; + let force_change_output = matches!( + (&swap.data.data_type, swap.quote.provider_data.provider), + (SwapQuoteDataType::Contract, SwapProvider::Chainflip) + ); + let is_max = match (&swap.data.data_type, swap.quote.provider_data.provider, swap.quote.use_max_amount) { + // Chainflip vault swaps require a third change output as the refund address. + (SwapQuoteDataType::Contract, SwapProvider::Chainflip, _) => false, + (SwapQuoteDataType::Contract | SwapQuoteDataType::Transfer, _, Some(use_max)) => use_max, + (SwapQuoteDataType::Contract | SwapQuoteDataType::Transfer, _, None) => input.is_max_value, + }; + + Ok(Self { + chain, + sender_address: input.sender_address.clone(), + destination_address: swap.data.to.clone(), + amount: swap + .data + .value + .parse::() + .map_err(|_| SignerError::invalid_input(format!("invalid {} swap amount", chain.get_chain())))?, + is_max, + force_change_output, + fee_rate: spend_fee_rate(chain, input)?, + memo, + utxos: input.metadata.get_utxos()?, + }) + } +} + +fn spend_fee_rate(chain: BitcoinChain, input: &SignerInput) -> Result { + let minimum_fee_rate = u64::try_from(chain.minimum_byte_fee()).map_err(|_| SignerError::invalid_input(format!("invalid {} minimum fee", chain.get_chain())))?; + Ok(input.fee.gas_price_u64()?.max(minimum_fee_rate)) +} + +fn validate_native_chain_asset(chain: BitcoinChain, asset: &Asset, message: &'static str) -> Result<(), SignerError> { + (asset.id.chain == chain.get_chain() && asset.id.is_native()) + .then_some(()) + .ok_or_else(|| SignerError::invalid_input(message)) +} diff --git a/core/crates/gem_bitcoin/src/signer/planner/spend.rs b/core/crates/gem_bitcoin/src/signer/planner/spend.rs new file mode 100644 index 000000000..b415e6be0 --- /dev/null +++ b/core/crates/gem_bitcoin/src/signer/planner/spend.rs @@ -0,0 +1,260 @@ +use bitcoin::ScriptBuf; +use primitives::{BitcoinChain, SignerError}; + +use super::{ + PlanInput, PlanOutput, SpendPlan, SpendRequest, + fee::estimate_fee, + inputs::spendable_inputs, + outputs::{dust_threshold, op_return_output, spend_outputs}, +}; +use crate::signer::address::script_for_address; + +pub(crate) struct UtxoPlanner; + +#[derive(Debug, Clone, Copy)] +enum SpendTarget { + Exact(u64), + Max, +} + +impl UtxoPlanner { + pub(crate) fn plan(request: SpendRequest) -> Result { + if request.utxos.is_empty() { + return SignerError::invalid_input_err("missing input UTXOs"); + } + if !request.is_max && request.amount == 0 { + return SignerError::invalid_input_err("invalid transaction amount"); + } + + let payment_script = script_for_address(request.chain, &request.destination_address)?.script_pubkey; + if !request.is_max && request.amount < dust_threshold(&payment_script) { + return Err(SignerError::DustThreshold); + } + + let change_script = script_for_address(request.chain, &request.sender_address)?.script_pubkey; + let memo_output = request.memo.as_deref().map(op_return_output).transpose()?; + let spendable_inputs = spendable_inputs(request.chain, &request.sender_address, request.utxos)?; + let target = if request.is_max { SpendTarget::Max } else { SpendTarget::Exact(request.amount) }; + Self::select_inputs_and_build_plan( + request.chain, + target, + request.fee_rate, + payment_script, + change_script, + memo_output, + spendable_inputs, + request.force_change_output, + ) + } + + fn select_inputs_and_build_plan( + chain: BitcoinChain, + target: SpendTarget, + fee_rate: u64, + payment_script: ScriptBuf, + change_script: ScriptBuf, + memo_output: Option, + mut spendable_inputs: Vec, + force_change_output: bool, + ) -> Result { + // Smallest-first so Exact selects the fewest inputs; Max spends all, so sorting just keeps it deterministic. + spendable_inputs.sort_by(|left, right| { + left.value + .to_sat() + .cmp(&right.value.to_sat()) + .then_with(|| left.previous_output.cmp(&right.previous_output)) + }); + + let input_count = spendable_inputs.len(); + let total: u128 = spendable_inputs.iter().map(|input| input.value.to_sat() as u128).sum(); + if total > u64::MAX as u128 { + return SignerError::invalid_input_err("Bitcoin amount overflow"); + } + let mut selected = Vec::new(); + let mut selected_amount = 0u64; + for (index, candidate) in spendable_inputs.into_iter().enumerate() { + selected_amount += candidate.value.to_sat(); + selected.push(candidate); + + let value = match target { + // Max spends every input, so only build the plan once all inputs are gathered. + SpendTarget::Max if index + 1 < input_count => continue, + SpendTarget::Max => return Self::build_max_plan(chain, fee_rate, &payment_script, &memo_output, &selected, selected_amount), + SpendTarget::Exact(value) => value, + }; + if let Some(plan) = Self::build_exact_plan( + chain, + value, + fee_rate, + &payment_script, + &change_script, + &memo_output, + &selected, + selected_amount, + force_change_output, + )? { + return Ok(plan); + } + } + + Err(SignerError::InsufficientFunds) + } + + fn build_max_plan( + chain: BitcoinChain, + fee_rate: u64, + payment_script: &ScriptBuf, + memo_output: &Option, + selected: &[PlanInput], + selected_amount: u64, + ) -> Result { + // Output value doesn't affect fee size; size the fee from the output shape, then spend the rest. + let mut outputs = spend_outputs(0, payment_script.clone(), memo_output.clone()); + let fee = estimate_fee(chain, selected, &outputs, fee_rate); + let value = selected_amount.checked_sub(fee).ok_or(SignerError::InsufficientFunds)?; + if value == 0 || value < dust_threshold(payment_script) { + return Err(SignerError::InsufficientFunds); + } + outputs[0].value = bitcoin::Amount::from_sat(value); + Ok(SpendPlan { + inputs: selected.to_vec(), + outputs, + fee, + }) + } + + fn build_exact_plan( + chain: BitcoinChain, + value: u64, + fee_rate: u64, + payment_script: &ScriptBuf, + change_script: &ScriptBuf, + memo_output: &Option, + selected: &[PlanInput], + selected_amount: u64, + force_change_output: bool, + ) -> Result, SignerError> { + let mut outputs = spend_outputs(value, payment_script.clone(), memo_output.clone()); + + let mut outputs_with_change = outputs.clone(); + outputs_with_change.push(PlanOutput::new(0, change_script.clone())); + + let fee_with_change = estimate_fee(chain, selected, &outputs_with_change, fee_rate); + let Some(remainder) = selected_amount.checked_sub(value).and_then(|remaining| remaining.checked_sub(fee_with_change)) else { + return Ok(None); + }; + + if remainder >= dust_threshold(change_script) { + outputs.push(PlanOutput::new(remainder, change_script.clone())); + return Ok(Some(SpendPlan { + inputs: selected.to_vec(), + outputs, + fee: fee_with_change, + })); + } + if force_change_output { + return Ok(None); + } + + let fee_without_change = estimate_fee(chain, selected, &outputs, fee_rate); + let Some(remainder) = selected_amount.checked_sub(value).and_then(|remaining| remaining.checked_sub(fee_without_change)) else { + return Ok(None); + }; + Ok(Some(SpendPlan { + inputs: selected.to_vec(), + outputs, + fee: fee_without_change + remainder, + })) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testkit::{ + address_mock::TEST_BITCOIN_P2WPKH_ADDRESS, + planner_mock::{mock_signer_input, mock_signer_input_with, mock_spend_utxos, sum_inputs}, + signer_mock::{TEST_UTXO_TXID, mock_utxo_with}, + }; + + #[test] + fn test_plan_transfer() { + let request = SpendRequest::transfer(BitcoinChain::Bitcoin, &mock_signer_input("12000", false)).unwrap(); + let plan = UtxoPlanner::plan(request).unwrap(); + assert_eq!(plan.inputs.len(), 2); + assert_eq!(plan.outputs.len(), 3); + assert_eq!(plan.outputs[0].value.to_sat(), 12_000); + assert_eq!(plan.outputs[1].value.to_sat(), 0); + assert!(plan.outputs[1].script_pubkey.is_op_return()); + assert_eq!( + sum_inputs(&plan.inputs).unwrap(), + plan.outputs.iter().map(|output| output.value.to_sat()).sum::() + plan.fee + ); + assert_eq!(plan.fee, 454); + + // Leftover is below P2WPKH change dust (~294), so it is absorbed into the fee. + let request = SpendRequest::transfer(BitcoinChain::Bitcoin, &mock_signer_input("9600", false)).unwrap(); + let plan = UtxoPlanner::plan(request).unwrap(); + assert_eq!(plan.inputs.len(), 1); + assert_eq!(plan.outputs.len(), 2); + assert_eq!(plan.outputs[0].value.to_sat(), 9_600); + assert!(plan.outputs[1].script_pubkey.is_op_return()); + assert_eq!(sum_inputs(&plan.inputs).unwrap(), 9_600 + plan.outputs[1].value.to_sat() + plan.fee); + assert_eq!(plan.outputs[1].value.to_sat(), 0); + + let request = SpendRequest::transfer(BitcoinChain::Bitcoin, &mock_signer_input("50000", false)).unwrap(); + assert_eq!(UtxoPlanner::plan(request).unwrap_err(), SignerError::InsufficientFunds); + + let request = SpendRequest::transfer(BitcoinChain::Bitcoin, &mock_signer_input("545", false)).unwrap(); + assert_eq!(UtxoPlanner::plan(request).unwrap_err(), SignerError::DustThreshold); + + let dust_max_utxos = vec![mock_utxo_with(TEST_UTXO_TXID, 0, "600", TEST_BITCOIN_P2WPKH_ADDRESS)]; + let request = SpendRequest::transfer(BitcoinChain::Bitcoin, &mock_signer_input_with("0", true, None, dust_max_utxos)).unwrap(); + assert_eq!(UtxoPlanner::plan(request).unwrap_err(), SignerError::InsufficientFunds); + + let request = SpendRequest::transfer(BitcoinChain::Bitcoin, &mock_signer_input_with("1000", false, Some("a".repeat(81)), mock_spend_utxos())).unwrap(); + assert_eq!(UtxoPlanner::plan(request).unwrap_err(), SignerError::invalid_input("Bitcoin memo is too large")); + + let request = SpendRequest::transfer(BitcoinChain::Bitcoin, &mock_signer_input("0", true)).unwrap(); + let plan = UtxoPlanner::plan(request).unwrap(); + assert_eq!(plan.inputs.len(), 2); + assert_eq!(plan.outputs.len(), 2); + assert!(plan.outputs[1].script_pubkey.is_op_return()); + assert_eq!( + sum_inputs(&plan.inputs).unwrap(), + plan.outputs.iter().map(|output| output.value.to_sat()).sum::() + plan.fee + ); + } + + #[test] + fn test_plan_absorbs_sub_dust_change_into_fee() { + let request = SpendRequest::transfer(BitcoinChain::Bitcoin, &mock_signer_input("9600", false)).unwrap(); + let change_script = script_for_address(request.chain, &request.sender_address).unwrap().script_pubkey; + let plan = UtxoPlanner::plan(request).unwrap(); + + assert_eq!(plan.outputs.len(), 2); + assert_eq!(plan.outputs[0].value.to_sat(), 9_600); + assert!(plan.outputs[1].script_pubkey.is_op_return()); + + let selected_amount = sum_inputs(&plan.inputs).unwrap(); + let fee_without_change = estimate_fee(BitcoinChain::Bitcoin, &plan.inputs, &plan.outputs, 2); + let mut outputs_with_change = plan.outputs.clone(); + outputs_with_change.push(PlanOutput::new(0, change_script.clone())); + let fee_with_change = estimate_fee(BitcoinChain::Bitcoin, &plan.inputs, &outputs_with_change, 2); + let dust_remainder = selected_amount - plan.outputs[0].value.to_sat() - fee_with_change; + assert!(dust_remainder > 0); + assert!(dust_remainder < dust_threshold(&change_script)); + + let absorbed_remainder = selected_amount - plan.outputs[0].value.to_sat() - fee_without_change; + assert_eq!(plan.fee, fee_without_change + absorbed_remainder); + assert_eq!(selected_amount, plan.outputs.iter().map(|output| output.value.to_sat()).sum::() + plan.fee); + } + + #[test] + fn test_dust_threshold_is_script_aware() { + let p2wpkh = script_for_address(BitcoinChain::Bitcoin, TEST_BITCOIN_P2WPKH_ADDRESS).unwrap().script_pubkey; + let p2pkh = script_for_address(BitcoinChain::Bitcoin, "1BoatSLRHtKNngkdXEeobR76b53LETtpyT").unwrap().script_pubkey; + assert_eq!(dust_threshold(&p2pkh), 546); + assert!(dust_threshold(&p2wpkh) > 0 && dust_threshold(&p2wpkh) < dust_threshold(&p2pkh)); + } +} diff --git a/core/crates/gem_bitcoin/src/signer/planner/types.rs b/core/crates/gem_bitcoin/src/signer/planner/types.rs new file mode 100644 index 000000000..1cbdc6a07 --- /dev/null +++ b/core/crates/gem_bitcoin/src/signer/planner/types.rs @@ -0,0 +1,38 @@ +use bitcoin::{Amount, OutPoint, ScriptBuf}; + +use crate::signer::{address::UnlockingScript, encoding::varint_len}; + +#[derive(Debug, Clone)] +pub(crate) struct PlanInput { + pub(crate) previous_output: OutPoint, + pub(crate) value: Amount, + pub(crate) script_pubkey: ScriptBuf, + pub(crate) unlocking_script: UnlockingScript, + pub(crate) sequence: u32, +} + +#[derive(Debug, Clone)] +pub(crate) struct PlanOutput { + pub(crate) value: Amount, + pub(crate) script_pubkey: ScriptBuf, +} + +impl PlanOutput { + pub(crate) fn new(value: u64, script_pubkey: ScriptBuf) -> Self { + Self { + value: Amount::from_sat(value), + script_pubkey, + } + } + + pub(crate) fn serialized_len(&self) -> u64 { + 8 + varint_len(self.script_pubkey.len()) as u64 + self.script_pubkey.len() as u64 + } +} + +#[derive(Debug, Clone)] +pub(crate) struct SpendPlan { + pub(crate) inputs: Vec, + pub(crate) outputs: Vec, + pub(crate) fee: u64, +} diff --git a/core/crates/gem_bitcoin/src/signer/transaction.rs b/core/crates/gem_bitcoin/src/signer/transaction.rs new file mode 100644 index 000000000..628dde378 --- /dev/null +++ b/core/crates/gem_bitcoin/src/signer/transaction.rs @@ -0,0 +1,166 @@ +use bitcoin::{ + PublicKey, ScriptBuf, Sequence, Transaction, TxIn as TransactionInput, TxOut as TransactionOutput, Witness, + absolute::LockTime, + blockdata::{script::Builder, transaction::Version}, + consensus::encode::serialize, + script::PushBytesBuf, + secp256k1::{Message, PublicKey as Secp256k1PublicKey, Secp256k1, SecretKey, Signing}, + sighash::{EcdsaSighashType, SighashCache}, +}; +use primitives::{BitcoinChain, SignerError}; + +use crate::signer::{ + address::{UnlockingScript, public_key_hash, script_for_public_key_hash}, + bitcoin_cash::sign_plan as sign_bitcoin_cash, + planner::SpendPlan, + zcash::sign_transparent, +}; + +/// Wipes the secp256k1 `SecretKey` on drop (it is not `ZeroizeOnDrop`), covering early-return paths. +struct ZeroizedSecretKey(SecretKey); + +impl Drop for ZeroizedSecretKey { + fn drop(&mut self) { + self.0.non_secure_erase(); + } +} + +pub(crate) fn sign_plan(chain: BitcoinChain, plan: &SpendPlan, private_key: &[u8], zcash_branch_id: Option) -> Result { + let secret_key = ZeroizedSecretKey(SecretKey::from_slice(private_key).map_err(|_| SignerError::invalid_input(format!("invalid {} private key", chain.get_chain())))?); + let secp = Secp256k1::signing_only(); + let public_key = PublicKey::new(Secp256k1PublicKey::from_secret_key(&secp, &secret_key.0)); + validate_chain_input_types(chain, plan)?; + validate_public_key(chain, plan, &public_key)?; + validate_plan_amounts(chain, plan)?; + + let transaction = match chain { + BitcoinChain::BitcoinCash => sign_bitcoin_cash(plan, &secret_key.0, &public_key, &secp)?, + BitcoinChain::Bitcoin | BitcoinChain::Litecoin | BitcoinChain::Doge => sign_standard(plan, &secret_key.0, &public_key, &secp)?, + BitcoinChain::Zcash => { + let branch_id = zcash_branch_id.ok_or_else(|| SignerError::invalid_input("missing Zcash branch id"))?; + return sign_transparent(plan, branch_id, &secret_key.0, &public_key, &secp); + } + }; + + Ok(hex::encode(serialize(&transaction))) +} + +pub(super) fn build_unsigned_transaction(plan: &SpendPlan) -> Transaction { + let input = plan + .inputs + .iter() + .map(|input| TransactionInput { + previous_output: input.previous_output, + script_sig: ScriptBuf::new(), + sequence: Sequence(input.sequence), + witness: Witness::default(), + }) + .collect(); + + let output = plan + .outputs + .iter() + .map(|output| TransactionOutput { + value: output.value, + script_pubkey: output.script_pubkey.clone(), + }) + .collect(); + + Transaction { + version: Version::TWO, + lock_time: LockTime::ZERO, + input, + output, + } +} + +fn sign_standard(plan: &SpendPlan, secret_key: &SecretKey, public_key: &PublicKey, secp: &Secp256k1) -> Result { + let mut transaction = build_unsigned_transaction(plan); + let signed_inputs = { + let mut sighash_cache = SighashCache::new(&transaction); + let mut signed_inputs = Vec::with_capacity(plan.inputs.len()); + + for (index, input) in plan.inputs.iter().enumerate() { + match input.unlocking_script { + UnlockingScript::P2pkh => { + let sighash = sighash_cache + .legacy_signature_hash(index, &input.script_pubkey, EcdsaSighashType::All.to_u32()) + .map_err(|e| SignerError::signing_error(e.to_string()))?; + let signature = der_signature(secp, secret_key, Message::from(sighash), EcdsaSighashType::All.to_u32() as u8); + let script_sig = Builder::new().push_slice(signature_push_bytes(signature)?).push_key(public_key).into_script(); + signed_inputs.push((script_sig, Witness::default())); + } + UnlockingScript::P2wpkh => { + let sighash = sighash_cache + .p2wpkh_signature_hash(index, &input.script_pubkey, input.value, EcdsaSighashType::All) + .map_err(|e| SignerError::signing_error(e.to_string()))?; + let signature = der_signature(secp, secret_key, Message::from(sighash), EcdsaSighashType::All.to_u32() as u8); + let mut witness = Witness::default(); + witness.push(signature); + witness.push(public_key.to_bytes()); + signed_inputs.push((ScriptBuf::new(), witness)); + } + } + } + signed_inputs + }; + + for (input, (script_sig, witness)) in transaction.input.iter_mut().zip(signed_inputs) { + input.script_sig = script_sig; + input.witness = witness; + } + + Ok(transaction) +} + +fn validate_chain_input_types(chain: BitcoinChain, plan: &SpendPlan) -> Result<(), SignerError> { + for input in &plan.inputs { + match chain { + BitcoinChain::Bitcoin | BitcoinChain::Litecoin | BitcoinChain::Doge => {} + BitcoinChain::BitcoinCash | BitcoinChain::Zcash => { + (input.unlocking_script == UnlockingScript::P2pkh) + .then_some(()) + .ok_or_else(|| SignerError::invalid_input(format!("{} UTXO address type is unsupported", chain.get_chain())))?; + } + } + } + Ok(()) +} + +fn validate_public_key(chain: BitcoinChain, plan: &SpendPlan, public_key: &PublicKey) -> Result<(), SignerError> { + let key_hash = public_key_hash(&public_key.to_bytes()); + for input in &plan.inputs { + let expected = script_for_public_key_hash(input.unlocking_script, key_hash); + (expected == input.script_pubkey) + .then_some(()) + .ok_or_else(|| SignerError::invalid_input(format!("{} private key does not match sender address", chain.get_chain())))?; + } + Ok(()) +} + +fn validate_plan_amounts(chain: BitcoinChain, plan: &SpendPlan) -> Result<(), SignerError> { + let input_total: u128 = plan.inputs.iter().map(|input| input.value.to_sat() as u128).sum(); + let output_total: u128 = plan.outputs.iter().map(|output| output.value.to_sat() as u128).sum(); + let spent_total = output_total + plan.fee as u128; + (input_total == spent_total).then_some(()).ok_or_else(|| { + SignerError::invalid_input(format!( + "{} plan amount mismatch: inputs {}, outputs {}, fee {}", + chain.get_chain(), + input_total, + output_total, + plan.fee + )) + })?; + Ok(()) +} + +pub(super) fn der_signature(secp: &Secp256k1, secret_key: &SecretKey, message: Message, sighash_byte: u8) -> Vec { + let signature = secp.sign_ecdsa(&message, secret_key); + let mut bytes = signature.serialize_der().to_vec(); + bytes.push(sighash_byte); + bytes +} + +pub(super) fn signature_push_bytes(signature: Vec) -> Result { + PushBytesBuf::try_from(signature).map_err(|_| SignerError::signing_error("invalid Bitcoin script push")) +} diff --git a/core/crates/gem_bitcoin/src/signer/zcash.rs b/core/crates/gem_bitcoin/src/signer/zcash.rs new file mode 100644 index 000000000..cbebd8b16 --- /dev/null +++ b/core/crates/gem_bitcoin/src/signer/zcash.rs @@ -0,0 +1,232 @@ +use bitcoin::{ + PublicKey, ScriptBuf, Sequence, TxIn as TransactionInput, TxOut as TransactionOutput, Witness, + blockdata::script::Builder, + consensus::encode::serialize, + secp256k1::{Message, Secp256k1, SecretKey, Signing}, +}; +use gem_hash::blake2::blake2b_256_personal; +use primitives::SignerError; + +use crate::signer::{ + encoding::encode_varint, + personalization::*, + planner::{PlanInput, SpendPlan}, + transaction::{der_signature, signature_push_bytes}, +}; + +const OVERWINTERED_VERSION_5: u32 = 0x8000_0005; +const VERSION_GROUP_ID_V5: u32 = 0x26a7_270a; +const LOCK_TIME: u32 = 0; +const EXPIRY_HEIGHT_DISABLED: u32 = 0; +const SIGHASH_ALL: u8 = 0x01; + +#[derive(Debug, Clone)] +struct ZcashTransparentTransaction { + branch_id: u32, + inputs: Vec, + outputs: Vec, +} + +struct ZcashSignatureDigests { + header: [u8; 32], + prevouts: [u8; 32], + amounts: [u8; 32], + script_pubkeys: [u8; 32], + sequences: [u8; 32], + outputs: [u8; 32], + sapling: [u8; 32], + orchard: [u8; 32], +} + +impl ZcashSignatureDigests { + fn new(transaction: &ZcashTransparentTransaction, plan: &SpendPlan) -> Result { + let prevouts = plan.inputs.iter().flat_map(|input| serialize(&input.previous_output)).collect::>(); + let amounts = plan.inputs.iter().map(signed_value_bytes).collect::, _>>()?.concat(); + let script_pubkeys = plan.inputs.iter().flat_map(|input| serialize(input.script_pubkey.as_script())).collect::>(); + let sequences = plan.inputs.iter().flat_map(|input| input.sequence.to_le_bytes()).collect::>(); + let outputs = transaction.outputs.iter().flat_map(serialize).collect::>(); + + Ok(Self { + header: header_digest(transaction.branch_id), + prevouts: blake2b_256_personal(&prevouts, ZCASH_TXID_PREVOUTS_HASH_PERSONALIZATION), + amounts: blake2b_256_personal(&amounts, ZCASH_TRANSPARENT_AMOUNTS_HASH_PERSONALIZATION), + script_pubkeys: blake2b_256_personal(&script_pubkeys, ZCASH_TRANSPARENT_SCRIPTS_HASH_PERSONALIZATION), + sequences: blake2b_256_personal(&sequences, ZCASH_TXID_SEQUENCES_HASH_PERSONALIZATION), + outputs: blake2b_256_personal(&outputs, ZCASH_TXID_OUTPUTS_HASH_PERSONALIZATION), + sapling: blake2b_256_personal(&[], ZCASH_TXID_SAPLING_HASH_PERSONALIZATION), + orchard: blake2b_256_personal(&[], ZCASH_TXID_ORCHARD_HASH_PERSONALIZATION), + }) + } +} + +impl ZcashTransparentTransaction { + fn unsigned(plan: &SpendPlan, branch_id: u32) -> Self { + let inputs = plan + .inputs + .iter() + .map(|input| TransactionInput { + previous_output: input.previous_output, + script_sig: ScriptBuf::new(), + sequence: Sequence(input.sequence), + witness: Witness::default(), + }) + .collect(); + + let outputs = plan + .outputs + .iter() + .map(|output| TransactionOutput { + value: output.value, + script_pubkey: output.script_pubkey.clone(), + }) + .collect(); + + Self { branch_id, inputs, outputs } + } + + fn encode(&self) -> Vec { + let mut bytes = Vec::new(); + bytes.extend_from_slice(&OVERWINTERED_VERSION_5.to_le_bytes()); + bytes.extend_from_slice(&VERSION_GROUP_ID_V5.to_le_bytes()); + bytes.extend_from_slice(&self.branch_id.to_le_bytes()); + bytes.extend_from_slice(&LOCK_TIME.to_le_bytes()); + bytes.extend_from_slice(&EXPIRY_HEIGHT_DISABLED.to_le_bytes()); + + bytes.extend_from_slice(&encode_varint(self.inputs.len())); + for input in &self.inputs { + bytes.extend_from_slice(&serialize(input)); + } + + bytes.extend_from_slice(&encode_varint(self.outputs.len())); + for output in &self.outputs { + bytes.extend_from_slice(&serialize(output)); + } + + bytes.extend_from_slice(&encode_varint(0)); + bytes.extend_from_slice(&encode_varint(0)); + bytes.extend_from_slice(&encode_varint(0)); + bytes + } +} + +pub(crate) fn sign_transparent(plan: &SpendPlan, branch_id: u32, secret_key: &SecretKey, public_key: &PublicKey, secp: &Secp256k1) -> Result { + let mut transaction = ZcashTransparentTransaction::unsigned(plan, branch_id); + let digests = ZcashSignatureDigests::new(&transaction, plan)?; + + for (index, _) in plan.inputs.iter().enumerate() { + let sighash = signature_digest(transaction.branch_id, &digests, plan, index)?; + let signature = der_signature(secp, secret_key, Message::from_digest(sighash), SIGHASH_ALL); + transaction.inputs[index].script_sig = Builder::new().push_slice(signature_push_bytes(signature)?).push_key(public_key).into_script(); + } + + Ok(hex::encode(transaction.encode())) +} + +fn signature_digest(branch_id: u32, digests: &ZcashSignatureDigests, plan: &SpendPlan, input_index: usize) -> Result<[u8; 32], SignerError> { + let transparent_sig_digest = transparent_sig_digest(digests, plan, input_index)?; + + let mut bytes = Vec::with_capacity(32 * 4); + bytes.extend_from_slice(&digests.header); + bytes.extend_from_slice(&transparent_sig_digest); + bytes.extend_from_slice(&digests.sapling); + bytes.extend_from_slice(&digests.orchard); + + Ok(blake2b_256_personal(&bytes, &branch_personalization(ZCASH_TX_HASH_PERSONALIZATION_PREFIX, branch_id))) +} + +fn header_digest(branch_id: u32) -> [u8; 32] { + let mut bytes = Vec::with_capacity(20); + bytes.extend_from_slice(&OVERWINTERED_VERSION_5.to_le_bytes()); + bytes.extend_from_slice(&VERSION_GROUP_ID_V5.to_le_bytes()); + bytes.extend_from_slice(&branch_id.to_le_bytes()); + bytes.extend_from_slice(&LOCK_TIME.to_le_bytes()); + bytes.extend_from_slice(&EXPIRY_HEIGHT_DISABLED.to_le_bytes()); + blake2b_256_personal(&bytes, ZCASH_TXID_HEADERS_HASH_PERSONALIZATION) +} + +fn transparent_sig_digest(digests: &ZcashSignatureDigests, plan: &SpendPlan, input_index: usize) -> Result<[u8; 32], SignerError> { + let mut bytes = Vec::with_capacity(1 + 32 * 6); + bytes.push(SIGHASH_ALL); + bytes.extend_from_slice(&digests.prevouts); + bytes.extend_from_slice(&digests.amounts); + bytes.extend_from_slice(&digests.script_pubkeys); + bytes.extend_from_slice(&digests.sequences); + bytes.extend_from_slice(&digests.outputs); + bytes.extend_from_slice(&txin_sig_digest(plan, input_index)?); + Ok(blake2b_256_personal(&bytes, ZCASH_TXID_TRANSPARENT_HASH_PERSONALIZATION)) +} + +fn txin_sig_digest(plan: &SpendPlan, input_index: usize) -> Result<[u8; 32], SignerError> { + let input = plan.inputs.get(input_index).ok_or_else(|| SignerError::signing_error("Zcash input index out of bounds"))?; + let mut bytes = Vec::new(); + bytes.extend_from_slice(&serialize(&input.previous_output)); + bytes.extend_from_slice(&signed_value_bytes(input)?); + bytes.extend_from_slice(&serialize(input.script_pubkey.as_script())); + bytes.extend_from_slice(&input.sequence.to_le_bytes()); + Ok(blake2b_256_personal(&bytes, ZCASH_TXIN_HASH_PERSONALIZATION)) +} + +fn signed_value_bytes(input: &PlanInput) -> Result<[u8; 8], SignerError> { + let value = i64::try_from(input.value.to_sat()).map_err(|_| SignerError::invalid_input("invalid Zcash UTXO amount"))?; + Ok(value.to_le_bytes()) +} + +fn branch_personalization(prefix: &[u8; 12], branch_id: u32) -> [u8; 16] { + let mut personal = [0u8; 16]; + personal[..12].copy_from_slice(prefix); + personal[12..].copy_from_slice(&branch_id.to_le_bytes()); + personal +} + +#[cfg(test)] +mod tests { + use bitcoin::secp256k1::{Secp256k1, SecretKey}; + use primitives::{BitcoinChain, testkit::zcash_mock}; + + use super::*; + use crate::{ + signer::planner::{SpendRequest, UtxoPlanner}, + testkit::{ + address_mock::{mock_public_key, mock_sender_address as test_sender_address, mock_zec_address}, + signer_mock::TEST_PRIVATE_KEY, + }, + }; + + #[test] + fn test_sign_transparent() { + let public_key = mock_public_key(); + let sender_address = test_sender_address(BitcoinChain::Zcash); + let destination_address = mock_zec_address([2u8; 20]); + let input = zcash_mock::mock_signer_input(sender_address, destination_address); + let request = SpendRequest::transfer(BitcoinChain::Zcash, &input).unwrap(); + let plan = UtxoPlanner::plan(request).unwrap(); + + let branch_id = input.metadata.get_zcash_branch_id().unwrap(); + let transaction = ZcashTransparentTransaction::unsigned(&plan, branch_id); + let digests = ZcashSignatureDigests::new(&transaction, &plan).unwrap(); + assert_eq!( + hex::encode(signature_digest(branch_id, &digests, &plan, 0).unwrap()), + "0e9508ded3c1bbbf0a153622e1b5dee4303c33d45bcaa7fa1218cab57feeb065" + ); + + let raw = sign_transparent( + &plan, + branch_id, + &SecretKey::from_slice(&TEST_PRIVATE_KEY).unwrap(), + &public_key, + &Secp256k1::signing_only(), + ) + .unwrap(); + let bytes = hex::decode(raw).unwrap(); + assert_eq!(&bytes[..20], hex::decode("050000800a27a726f04dec4d0000000000000000").unwrap().as_slice()); + assert_eq!(plan.fee, 10_000); + assert_eq!(bytes[20], 1); + + let script_len_index = 20 + 1 + 32 + 4; + let script_len = bytes[script_len_index] as usize; + let signature_len = bytes[script_len_index + 1] as usize; + assert!(script_len > 0); + assert_eq!(bytes[script_len_index + 2 + signature_len - 1], SIGHASH_ALL); + assert_eq!(*bytes.last().unwrap(), 0); + } +} diff --git a/core/crates/gem_bitcoin/src/testkit/address_mock.rs b/core/crates/gem_bitcoin/src/testkit/address_mock.rs new file mode 100644 index 000000000..163ca86d6 --- /dev/null +++ b/core/crates/gem_bitcoin/src/testkit/address_mock.rs @@ -0,0 +1,92 @@ +use bitcoin::{ + PublicKey, + secp256k1::{PublicKey as Secp256k1PublicKey, Secp256k1, SecretKey}, +}; +use primitives::{BitcoinChain, testkit::signer_mock::TEST_PRIVATE_KEY}; + +use crate::{ + address::BitcoinAddress, + signer::address::{UnlockingScript, ZCASH_TRANSPARENT_P2PKH_PREFIX, public_key_hash, script_for_public_key_hash}, +}; + +pub(crate) const TEST_BITCOIN_P2WPKH_ADDRESS: &str = "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4"; +pub(crate) const TEST_BITCOIN_P2WPKH_HASH: [u8; 20] = [ + 0x75, 0x1e, 0x76, 0xe8, 0x19, 0x91, 0x96, 0xd4, 0x54, 0x94, 0x1c, 0x45, 0xd1, 0xb3, 0xa3, 0x23, 0xf1, 0x43, 0x3b, 0xd6, +]; + +impl BitcoinAddress { + pub fn mock() -> Self { + Self::mock_with_chain(BitcoinChain::Bitcoin) + } + + pub fn mock_with_chain(chain: BitcoinChain) -> Self { + Self::try_parse_for_chain(&Self::mock_address_with_chain(chain), chain).unwrap() + } + + pub fn mock_address_with_chain(chain: BitcoinChain) -> String { + match chain { + BitcoinChain::Bitcoin => TEST_BITCOIN_P2WPKH_ADDRESS.to_string(), + BitcoinChain::BitcoinCash => mock_addr_by_hash(chain, [2u8; 20]), + BitcoinChain::Litecoin => mock_addr_by_hash(chain, [3u8; 20]), + BitcoinChain::Doge => mock_addr_by_hash(chain, [4u8; 20]), + BitcoinChain::Zcash => mock_addr_by_hash(chain, [5u8; 20]), + } + } +} + +pub(crate) fn mock_addr_by_hash(chain: BitcoinChain, hash: [u8; 20]) -> String { + match chain { + BitcoinChain::Bitcoin => prefixed_address(&[0], hash), + BitcoinChain::BitcoinCash => mock_bch_address(hash), + BitcoinChain::Litecoin => prefixed_address(&[48], hash), + BitcoinChain::Doge => prefixed_address(&[30], hash), + BitcoinChain::Zcash => mock_zec_address(hash), + } +} + +pub(crate) fn mock_bch_address(hash: [u8; 20]) -> String { + bitcoincash_addr::Address::new( + hash.to_vec(), + bitcoincash_addr::Scheme::CashAddr, + bitcoincash_addr::HashType::Key, + bitcoincash_addr::Network::Main, + ) + .encode() + .unwrap() +} + +pub(crate) fn mock_zec_address(hash: [u8; 20]) -> String { + prefixed_address(&ZCASH_TRANSPARENT_P2PKH_PREFIX, hash) +} + +pub(crate) fn prefixed_address(prefix: &[u8], hash: [u8; 20]) -> String { + let mut payload = prefix.to_vec(); + payload.extend(hash); + bs58::encode(payload).with_check().into_string() +} + +pub fn mock_public_key() -> PublicKey { + let secp = Secp256k1::new(); + let secret_key = SecretKey::from_slice(&TEST_PRIVATE_KEY).unwrap(); + PublicKey::new(Secp256k1PublicKey::from_secret_key(&secp, &secret_key)) +} + +pub fn mock_sender_address(chain: BitcoinChain) -> String { + let public_key = mock_public_key(); + mock_addr_by_hash(chain, public_key_hash(&public_key.to_bytes())) +} + +pub fn mock_destination_address(chain: BitcoinChain) -> String { + let hash = match chain { + BitcoinChain::Bitcoin => public_key_hash(&mock_public_key().to_bytes()), + BitcoinChain::BitcoinCash | BitcoinChain::Litecoin | BitcoinChain::Doge => [2u8; 20], + BitcoinChain::Zcash => [3u8; 20], + }; + mock_addr_by_hash(chain, hash) +} + +pub fn mock_p2wpkh_address() -> String { + let hash = public_key_hash(&mock_public_key().to_bytes()); + let script_pubkey = script_for_public_key_hash(UnlockingScript::P2wpkh, hash); + bitcoin::Address::from_script(&script_pubkey, bitcoin::Network::Bitcoin).unwrap().to_string() +} diff --git a/core/crates/gem_bitcoin/src/testkit/mod.rs b/core/crates/gem_bitcoin/src/testkit/mod.rs index c9fa0bff1..1cdc50a8d 100644 --- a/core/crates/gem_bitcoin/src/testkit/mod.rs +++ b/core/crates/gem_bitcoin/src/testkit/mod.rs @@ -1 +1,7 @@ +#[cfg(feature = "signer")] +pub mod address_mock; +#[cfg(feature = "signer")] +pub mod planner_mock; +#[cfg(feature = "signer")] +pub mod signer_mock; pub mod transaction_mock; diff --git a/core/crates/gem_bitcoin/src/testkit/planner_mock.rs b/core/crates/gem_bitcoin/src/testkit/planner_mock.rs new file mode 100644 index 000000000..0a0fc5406 --- /dev/null +++ b/core/crates/gem_bitcoin/src/testkit/planner_mock.rs @@ -0,0 +1,60 @@ +use bitcoin::{ + Amount, OutPoint, ScriptBuf, + blockdata::{opcodes::all::OP_RETURN, script::Builder}, + script::PushBytesBuf, +}; +use num_bigint::BigInt; +use primitives::{BitcoinChain, GasPriceType, SignerError, SignerInput, TransactionFee, UTXO}; + +use crate::{ + signer::{PlanInput, address::UnlockingScript}, + testkit::{ + address_mock::TEST_BITCOIN_P2WPKH_ADDRESS, + signer_mock::{TEST_UTXO_TXID, mock_transfer_input_with_utxos, mock_utxo_with}, + }, +}; + +pub(crate) const TEST_SPEND_RECIPIENT: &str = "1BoatSLRHtKNngkdXEeobR76b53LETtpyT"; + +impl PlanInput { + pub(crate) fn mock_with_unlocking_script(unlocking_script: UnlockingScript) -> Self { + Self { + previous_output: OutPoint::null(), + value: Amount::from_sat(50_000), + script_pubkey: ScriptBuf::new(), + unlocking_script, + sequence: u32::MAX, + } + } +} + +pub(crate) fn mock_signer_input(value: &str, is_max: bool) -> SignerInput { + mock_signer_input_with(value, is_max, Some("memo".to_string()), mock_spend_utxos()) +} + +pub(crate) fn mock_signer_input_with(value: &str, is_max: bool, memo: Option, utxos: Vec) -> SignerInput { + let mut input = mock_transfer_input_with_utxos(BitcoinChain::Bitcoin, TEST_BITCOIN_P2WPKH_ADDRESS, TEST_SPEND_RECIPIENT, value, utxos); + input.input.gas_price = GasPriceType::regular(BigInt::from(2u64)); + input.input.memo = memo; + input.input.is_max_value = is_max; + input.fee = TransactionFee::new_from_fee(BigInt::from(2u64)); + input +} + +pub(crate) fn mock_spend_utxos() -> Vec { + vec![ + mock_utxo_with(TEST_UTXO_TXID, 0, "10000", TEST_BITCOIN_P2WPKH_ADDRESS), + mock_utxo_with("0000000000000000000000000000000000000000000000000000000000000002", 1, "20000", TEST_BITCOIN_P2WPKH_ADDRESS), + ] +} + +pub(crate) fn op_return_script(bytes: usize) -> ScriptBuf { + let push = PushBytesBuf::try_from(vec![0u8; bytes]).unwrap(); + Builder::new().push_opcode(OP_RETURN).push_slice(push).into_script() +} + +pub(crate) fn sum_inputs(inputs: &[PlanInput]) -> Result { + inputs.iter().try_fold(0u64, |sum, input| { + sum.checked_add(input.value.to_sat()).ok_or_else(|| SignerError::invalid_input("Bitcoin amount overflow")) + }) +} diff --git a/core/crates/gem_bitcoin/src/testkit/signer_mock.rs b/core/crates/gem_bitcoin/src/testkit/signer_mock.rs new file mode 100644 index 000000000..74302e259 --- /dev/null +++ b/core/crates/gem_bitcoin/src/testkit/signer_mock.rs @@ -0,0 +1,138 @@ +use num_bigint::BigInt; +use primitives::{ + Asset, BitcoinChain, GasPriceType, SignerInput, SwapProvider, TransactionFee, TransactionInputType, TransactionLoadInput, TransactionLoadMetadata, UTXO, + swap::{SwapData, SwapProviderData, SwapQuote, SwapQuoteData}, +}; + +use crate::testkit::address_mock::{mock_destination_address, mock_p2wpkh_address, mock_sender_address}; + +pub use primitives::testkit::{signer_mock::TEST_PRIVATE_KEY, zcash_mock::TEST_ZCASH_BRANCH_ID}; + +pub const TEST_UTXO_TXID: &str = "0000000000000000000000000000000000000000000000000000000000000001"; + +pub fn mock_transfer_input(chain: BitcoinChain) -> SignerInput { + let sender_address = mock_sender_address(chain); + let destination_address = mock_destination_address(chain); + mock_transfer_input_with_utxos(chain, &sender_address, &destination_address, "10000", vec![mock_utxo_with_address(&sender_address)]) +} + +pub fn mock_transfer_input_with_utxos(chain: BitcoinChain, sender_address: &str, destination_address: &str, value: &str, utxos: Vec) -> SignerInput { + let metadata = match chain { + BitcoinChain::Zcash => TransactionLoadMetadata::Zcash { + branch_id: TEST_ZCASH_BRANCH_ID.to_string(), + utxos, + }, + _ => TransactionLoadMetadata::Bitcoin { utxos }, + }; + + SignerInput::new( + TransactionLoadInput { + input_type: TransactionInputType::Transfer(Asset::from_chain(chain.get_chain())), + sender_address: sender_address.to_string(), + destination_address: destination_address.to_string(), + value: value.to_string(), + gas_price: GasPriceType::regular(BigInt::from(1u64)), + memo: None, + is_max_value: false, + metadata, + }, + TransactionFee::new_from_fee(BigInt::from(1u64)), + ) +} + +pub fn mock_p2wpkh_transfer_input() -> SignerInput { + let address = mock_p2wpkh_address(); + mock_transfer_input_with_utxos( + BitcoinChain::Bitcoin, + &address, + &address, + "10000", + vec![mock_utxo_with(TEST_UTXO_TXID, 0, "50000", &address)], + ) +} + +pub fn mock_funded_transfer_input(chain: BitcoinChain) -> SignerInput { + let mut input = mock_transfer_input(chain); + match &mut input.input.metadata { + TransactionLoadMetadata::Bitcoin { utxos } | TransactionLoadMetadata::Zcash { utxos, .. } => { + utxos[0].value = "100000000".to_string(); + } + _ => {} + } + input +} + +pub fn mock_transfer_swap_input(chain: BitcoinChain, memo: &str) -> SignerInput { + mock_swap_input(chain, SwapProvider::Thorchain, Some(false), |destination_address, value| { + SwapQuoteData::new_tranfer(destination_address, value, Some(memo.to_string())) + }) +} + +pub fn mock_contract_swap_input(chain: BitcoinChain, nulldata_hex: &str, use_max_amount: bool) -> SignerInput { + mock_contract_swap_input_with_provider(chain, nulldata_hex, use_max_amount, SwapProvider::Chainflip) +} + +pub fn mock_contract_swap_input_with_provider(chain: BitcoinChain, nulldata_hex: &str, use_max_amount: bool, provider: SwapProvider) -> SignerInput { + mock_swap_input(chain, provider, Some(use_max_amount), |destination_address, value| { + SwapQuoteData::new_contract(destination_address, value, nulldata_hex.to_string(), None, None) + }) +} + +fn mock_swap_input(chain: BitcoinChain, provider: SwapProvider, use_max_amount: Option, quote_data: impl FnOnce(String, String) -> SwapQuoteData) -> SignerInput { + let mut input = mock_funded_transfer_input(chain); + let sender_address = input.sender_address.clone(); + let destination_address = input.destination_address.clone(); + let value = input.value.clone(); + + input.input.input_type = TransactionInputType::Swap( + Asset::from_chain(chain.get_chain()), + Asset::from_chain(BitcoinChain::Bitcoin.get_chain()), + SwapData { + quote: SwapQuote { + from_address: sender_address, + from_value: value.clone(), + min_from_value: None, + to_address: destination_address.clone(), + to_value: value.clone(), + provider_data: SwapProviderData { + provider, + name: provider.name().to_string(), + protocol_name: provider.protocol_name().to_string(), + }, + slippage_bps: 50, + eta_in_seconds: None, + use_max_amount, + }, + data: quote_data(destination_address, value), + }, + ); + input +} + +pub fn mock_p2wpkh_contract_swap_input(nulldata_hex: &str, use_max_amount: bool) -> SignerInput { + let p2wpkh_sender = mock_p2wpkh_address(); + let mut input = mock_contract_swap_input(BitcoinChain::Bitcoin, nulldata_hex, use_max_amount); + input.input.sender_address = p2wpkh_sender.clone(); + let TransactionLoadMetadata::Bitcoin { utxos } = &mut input.input.metadata else { + unreachable!() + }; + utxos[0].address = p2wpkh_sender.clone(); + let TransactionInputType::Swap(_, _, swap) = &mut input.input.input_type else { + unreachable!() + }; + swap.quote.from_address = p2wpkh_sender; + input +} + +pub(crate) fn mock_utxo_with(transaction_id: &str, vout: i32, value: &str, address: &str) -> UTXO { + UTXO { + transaction_id: transaction_id.to_string(), + vout, + value: value.to_string(), + address: address.to_string(), + } +} + +pub(crate) fn mock_utxo_with_address(address: &str) -> UTXO { + mock_utxo_with(TEST_UTXO_TXID, 0, "50000", address) +} diff --git a/core/crates/gem_cardano/src/planner.rs b/core/crates/gem_cardano/src/planner.rs index 88b037c01..0628e0bd9 100644 --- a/core/crates/gem_cardano/src/planner.rs +++ b/core/crates/gem_cardano/src/planner.rs @@ -124,7 +124,7 @@ fn utxo_transaction_input(utxo: &UTXO) -> Result } fn utxo_amount(utxo: &UTXO) -> Result { - let amount = utxo.value.parse::().map_err(|_| SignerError::invalid_input("invalid Cardano UTXO amount"))?; + let amount = utxo.value_u64().map_err(SignerError::from_display)?; if amount == 0 { return SignerError::invalid_input_err("invalid Cardano UTXO amount"); } diff --git a/core/crates/gem_hash/Cargo.toml b/core/crates/gem_hash/Cargo.toml index 1f0a2bf4c..6b8353220 100644 --- a/core/crates/gem_hash/Cargo.toml +++ b/core/crates/gem_hash/Cargo.toml @@ -4,7 +4,7 @@ version = { workspace = true } edition = { workspace = true } [dependencies] -blake2 = { workspace = true } +blake2b_simd = { workspace = true } hex = { workspace = true } sha2 = { workspace = true } sha3 = { workspace = true } diff --git a/core/crates/gem_hash/src/blake2.rs b/core/crates/gem_hash/src/blake2.rs index 460f967d1..9f43a3239 100644 --- a/core/crates/gem_hash/src/blake2.rs +++ b/core/crates/gem_hash/src/blake2.rs @@ -1,25 +1,38 @@ -use blake2::{ - Blake2b, Blake2b512, Digest, - digest::consts::{U28, U32}, -}; - -type Blake2b224 = Blake2b; -type Blake2b256 = Blake2b; - pub fn blake2b_224(bytes: &[u8]) -> [u8; 28] { - let mut hasher = Blake2b224::new(); - Digest::update(&mut hasher, bytes); - hasher.finalize().into() + blake2b(bytes) } pub fn blake2b_256(bytes: &[u8]) -> [u8; 32] { - let mut hasher = Blake2b256::new(); - Digest::update(&mut hasher, bytes); - hasher.finalize().into() + blake2b(bytes) +} + +pub fn blake2b_256_personal(bytes: &[u8], personal: &[u8; 16]) -> [u8; 32] { + let hash = blake2b_simd::Params::new().hash_length(32).personal(personal).hash(bytes); + let mut output = [0u8; 32]; + output.copy_from_slice(hash.as_bytes()); + output } pub fn blake2b_512(bytes: &[u8]) -> [u8; 64] { - let mut hasher = Blake2b512::new(); - Digest::update(&mut hasher, bytes); - hasher.finalize().into() + blake2b(bytes) +} + +fn blake2b(bytes: &[u8]) -> [u8; N] { + let hash = blake2b_simd::Params::new().hash_length(N).hash(bytes); + let mut output = [0u8; N]; + output.copy_from_slice(hash.as_bytes()); + output +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_blake2b_256_personal() { + assert_eq!( + hex::encode(blake2b_256_personal(&[], b"ZTxIdSaplingHash")), + "6f2fc8f98feafd94e74a0df4bed74391ee0b5a69945e4ced8ca8a095206f00ae" + ); + } } diff --git a/core/crates/primitives/src/chain_bitcoin.rs b/core/crates/primitives/src/chain_bitcoin.rs index 3fb5b375a..0d0c58760 100644 --- a/core/crates/primitives/src/chain_bitcoin.rs +++ b/core/crates/primitives/src/chain_bitcoin.rs @@ -5,6 +5,8 @@ use typeshare::typeshare; use crate::Chain; +pub const BITCOINCASH_PREFIX: &str = "bitcoincash:"; + #[derive(Copy, Clone, Debug, Serialize, Deserialize, EnumIter, AsRefStr, EnumString)] #[typeshare(swift = "Equatable, CaseIterable, Sendable")] #[serde(rename_all = "lowercase")] diff --git a/core/crates/primitives/src/lib.rs b/core/crates/primitives/src/lib.rs index d691214a9..7135f3884 100644 --- a/core/crates/primitives/src/lib.rs +++ b/core/crates/primitives/src/lib.rs @@ -30,7 +30,7 @@ pub use self::chain_transaction_timeout::{chain_transaction_timeout, swap_transa pub mod chain_evm; pub use self::chain_evm::EVMChain; pub mod chain_bitcoin; -pub use self::chain_bitcoin::BitcoinChain; +pub use self::chain_bitcoin::{BITCOINCASH_PREFIX, BitcoinChain}; pub mod name; pub use self::name::NameProvider; pub mod node; diff --git a/core/crates/primitives/src/signer_error.rs b/core/crates/primitives/src/signer_error.rs index fe201ac87..580fd50cf 100644 --- a/core/crates/primitives/src/signer_error.rs +++ b/core/crates/primitives/src/signer_error.rs @@ -1,9 +1,11 @@ use crate::{AddressError, HexError}; -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq)] pub enum SignerError { InvalidInput(String), SigningError(String), + DustThreshold, + InsufficientFunds, } impl std::fmt::Display for SignerError { @@ -11,6 +13,8 @@ impl std::fmt::Display for SignerError { match self { SignerError::InvalidInput(msg) => write!(f, "Invalid input: {}", msg), SignerError::SigningError(msg) => write!(f, "Signing error: {}", msg), + SignerError::DustThreshold => write!(f, "transaction amount is below the dust threshold"), + SignerError::InsufficientFunds => write!(f, "insufficient balance"), } } } diff --git a/core/crates/primitives/src/testkit/mod.rs b/core/crates/primitives/src/testkit/mod.rs index af0249a53..75e4151b9 100644 --- a/core/crates/primitives/src/testkit/mod.rs +++ b/core/crates/primitives/src/testkit/mod.rs @@ -22,3 +22,4 @@ pub mod transaction_state_request_mock; pub mod transfer_data_extra_mock; pub mod wallet_connect_mock; pub mod wallet_connection_session_mock; +pub mod zcash_mock; diff --git a/core/crates/primitives/src/testkit/zcash_mock.rs b/core/crates/primitives/src/testkit/zcash_mock.rs new file mode 100644 index 000000000..a3b71bcdc --- /dev/null +++ b/core/crates/primitives/src/testkit/zcash_mock.rs @@ -0,0 +1,29 @@ +use num_bigint::BigInt; + +use crate::{Asset, Chain, GasPriceType, SignerInput, TransactionFee, TransactionInputType, TransactionLoadInput, TransactionLoadMetadata, UTXO}; + +pub const TEST_ZCASH_BRANCH_ID: &str = "4dec4df0"; + +pub fn mock_signer_input(sender_address: String, destination_address: String) -> SignerInput { + SignerInput::new( + TransactionLoadInput { + input_type: TransactionInputType::Transfer(Asset::from_chain(Chain::Zcash)), + sender_address: sender_address.clone(), + destination_address, + value: "20000".to_string(), + gas_price: GasPriceType::regular(BigInt::from(1u64)), + memo: None, + is_max_value: false, + metadata: TransactionLoadMetadata::Zcash { + branch_id: TEST_ZCASH_BRANCH_ID.to_string(), + utxos: vec![UTXO { + transaction_id: "0000000000000000000000000000000000000000000000000000000000000001".to_string(), + vout: 0, + value: "50000".to_string(), + address: sender_address, + }], + }, + }, + TransactionFee::new_from_fee(BigInt::from(1u64)), + ) +} diff --git a/core/crates/primitives/src/transaction_load_metadata.rs b/core/crates/primitives/src/transaction_load_metadata.rs index 7f7146da7..7455971fa 100644 --- a/core/crates/primitives/src/transaction_load_metadata.rs +++ b/core/crates/primitives/src/transaction_load_metadata.rs @@ -131,6 +131,15 @@ impl TransactionLoadMetadata { } } + pub fn get_zcash_branch_id(&self) -> Option { + let TransactionLoadMetadata::Zcash { branch_id, .. } = self else { + return None; + }; + // Zcash branch id is big-endian hex from the rpc node. + let bytes: [u8; 4] = crate::decode_hex(branch_id).ok()?.try_into().ok()?; + Some(u32::from_be_bytes(bytes)) + } + pub fn get_account_number(&self) -> Result> { match self { TransactionLoadMetadata::Cosmos { account_number, .. } => Ok(*account_number), diff --git a/core/crates/primitives/src/utxo.rs b/core/crates/primitives/src/utxo.rs index 690564eac..8a88741b8 100644 --- a/core/crates/primitives/src/utxo.rs +++ b/core/crates/primitives/src/utxo.rs @@ -9,3 +9,9 @@ pub struct UTXO { pub value: String, pub address: String, } + +impl UTXO { + pub fn value_u64(&self) -> Result> { + self.value.parse().map_err(|_| format!("invalid UTXO amount: {}", self.value).into()) + } +} diff --git a/core/crates/swapper/src/across/provider.rs b/core/crates/swapper/src/across/provider.rs index 3ec1a2df7..05227431b 100644 --- a/core/crates/swapper/src/across/provider.rs +++ b/core/crates/swapper/src/across/provider.rs @@ -797,7 +797,7 @@ mod tests { #[cfg(all(test, feature = "swap_integration_tests", feature = "reqwest_provider"))] mod swap_integration_tests { use super::*; - use crate::{FetchQuoteData, NativeProvider, Options, QuoteRequest, SwapperError, fees::ReferralFee}; + use crate::{FetchQuoteData, NativeProvider, Options, QuoteRequest, SwapperError}; use primitives::{AssetId, Chain, swap::SwapStatus}; use std::{sync::Arc, time::SystemTime}; diff --git a/core/crates/swapper/src/chainflip/provider.rs b/core/crates/swapper/src/chainflip/provider.rs index e08e19ec9..a51fea78c 100644 --- a/core/crates/swapper/src/chainflip/provider.rs +++ b/core/crates/swapper/src/chainflip/provider.rs @@ -23,7 +23,7 @@ use crate::{ fees::{DEFAULT_CHAINFLIP_FEE_BPS, apply_slippage_in_bp, quote_value_after_reserve_by_chain}, solana::DEFAULT_SWAP_GAS_LIMIT, }; -use primitives::{ChainType, chain::Chain, swap::QuoteAsset}; +use primitives::{Asset, ChainType, chain::Chain, swap::QuoteAsset}; const DEFAULT_SWAP_ERC20_GAS_LIMIT: u64 = 100_000; @@ -59,37 +59,65 @@ where rpc_provider, } } +} - fn map_asset_id(asset: &QuoteAsset) -> ChainflipAsset { - let asset_id = asset.asset_id(); - let chain_name = capitalize_first_letter(asset_id.chain.as_ref()); - ChainflipAsset { - chain: chain_name, - asset: asset.symbol.clone(), - } - } +struct ChainflipQuoteRequestData { + from_value: String, + quote_request: ChainflipQuoteRequest, +} - fn get_quote_value(request: &QuoteRequest) -> Result { - let value = quote_value_after_reserve_by_chain(request)?; - if !request.options.use_max_amount || !request.from_asset.asset_id().is_native() { - return Ok(value); - } - match request.from_asset.chain() { - Chain::Solana => { - let amount: u64 = value.parse().map_err(|_| SwapperError::ComputeQuoteError(format!("invalid amount: {value}")))?; - let reserved = amount.saturating_sub(SOLANA_VAULT_SWAP_RESERVE); - if reserved == 0 { - return Err(SwapperError::InputAmountError { - min_amount: Some(SOLANA_VAULT_SWAP_RESERVE.to_string()), - }); - } - Ok(reserved.to_string()) +fn map_asset_id(asset: &QuoteAsset) -> ChainflipAsset { + let asset_id = asset.asset_id(); + let chain_name = capitalize_first_letter(asset_id.chain.as_ref()); + let symbol = if asset.symbol.is_empty() && asset_id.is_native() { + Asset::from_chain(asset_id.chain).symbol + } else { + asset.symbol.clone() + }; + ChainflipAsset { chain: chain_name, asset: symbol } +} + +fn get_quote_value(request: &QuoteRequest) -> Result { + let value = quote_value_after_reserve_by_chain(request)?; + if !request.options.use_max_amount || !request.from_asset.asset_id().is_native() { + return Ok(value); + } + match request.from_asset.chain() { + Chain::Solana => { + let amount: u64 = value.parse().map_err(|_| SwapperError::ComputeQuoteError(format!("invalid amount: {value}")))?; + let reserved = amount.saturating_sub(SOLANA_VAULT_SWAP_RESERVE); + if reserved == 0 { + return Err(SwapperError::InputAmountError { + min_amount: Some(SOLANA_VAULT_SWAP_RESERVE.to_string()), + }); } - _ => Ok(value), + Ok(reserved.to_string()) } + _ => Ok(value), } } +fn build_quote_request(request: &QuoteRequest) -> Result { + let from_value = get_quote_value(request)?; + let src_asset = map_asset_id(&request.from_asset); + let dest_asset = map_asset_id(&request.to_asset); + let fee_bps = DEFAULT_CHAINFLIP_FEE_BPS; + + Ok(ChainflipQuoteRequestData { + from_value: from_value.clone(), + quote_request: ChainflipQuoteRequest { + amount: from_value, + src_chain: src_asset.chain, + src_asset: src_asset.asset, + dest_chain: dest_asset.chain, + dest_asset: dest_asset.asset, + is_vault_swap: true, + dca_enabled: true, + broker_commission_bps: Some(fee_bps), + }, + }) +} + fn get_best_quote(mut quotes: Vec, fee_bps: u32) -> (BigUint, u32, u32, ChainflipRouteData) { quotes.sort_by(|a, b| b.egress_amount.cmp(&a.egress_amount)); let quote = "es[0]; @@ -171,27 +199,10 @@ where } async fn get_quote(&self, request: &QuoteRequest) -> Result { - if request.from_asset.chain().chain_type() == ChainType::Bitcoin { - return Err(SwapperError::NoQuoteAvailable); - } - - let from_value = Self::get_quote_value(request)?; - let src_asset = Self::map_asset_id(&request.from_asset); - let dest_asset = Self::map_asset_id(&request.to_asset); - let fee_bps = DEFAULT_CHAINFLIP_FEE_BPS; - let quote_request = ChainflipQuoteRequest { - amount: from_value.clone(), - src_chain: src_asset.chain.clone(), - src_asset: src_asset.asset.clone(), - dest_chain: dest_asset.chain, - dest_asset: dest_asset.asset, - is_vault_swap: true, - dca_enabled: true, - broker_commission_bps: Some(fee_bps), - }; + let quote_request_data = build_quote_request(request)?; - let quotes = match self.chainflip_client.get_quote("e_request).await { + let quotes = match self.chainflip_client.get_quote("e_request_data.quote_request).await { Ok(quotes) => quotes, Err(err) => return Err(map_chainflip_quote_error(err, request.from_asset.decimals)), }; @@ -202,8 +213,8 @@ where let (egress_amount, slippage_bps, eta_in_seconds, route_data) = get_best_quote(quotes, fee_bps); Ok(Quote { - from_value, min_from_value: None, + from_value: quote_request_data.from_value, to_value: egress_amount.to_string(), data: ProviderData { provider: self.provider.clone(), @@ -221,8 +232,8 @@ where async fn get_quote_data(&self, quote: &Quote, _data: FetchQuoteData) -> Result { let from_asset = quote.request.from_asset.asset_id(); - let source_asset = Self::map_asset_id("e.request.from_asset); - let destination_asset = Self::map_asset_id("e.request.to_asset); + let source_asset = map_asset_id("e.request.from_asset); + let destination_asset = map_asset_id("e.request.to_asset); let input_amount: BigUint = quote.from_value.parse()?; @@ -339,6 +350,8 @@ where #[cfg(test)] mod tests { use super::*; + use crate::{Options, SwapperQuoteAsset}; + use primitives::AssetId; #[test] fn test_chainflip_min_amount_error() { @@ -361,6 +374,31 @@ mod tests { ); } + #[test] + fn test_build_quote_request_supports_bitcoin_source() { + let request = QuoteRequest { + from_asset: SwapperQuoteAsset::from(AssetId::from_chain(Chain::Bitcoin)), + to_asset: SwapperQuoteAsset::from(AssetId::from_chain(Chain::Ethereum)), + value: "89100".to_string(), + options: Options { + use_max_amount: true, + ..Default::default() + }, + ..QuoteRequest::mock(Chain::Bitcoin, None) + }; + + let quote_request = build_quote_request(&request).unwrap(); + + assert_eq!(quote_request.from_value, "74100"); + assert_eq!(quote_request.quote_request.amount, "74100"); + assert_eq!(quote_request.quote_request.src_chain, "Bitcoin"); + assert_eq!(quote_request.quote_request.src_asset, "BTC"); + assert_eq!(quote_request.quote_request.dest_chain, "Ethereum"); + assert_eq!(quote_request.quote_request.dest_asset, "ETH"); + assert!(quote_request.quote_request.is_vault_swap); + assert_eq!(quote_request.quote_request.broker_commission_bps, Some(DEFAULT_CHAINFLIP_FEE_BPS)); + } + #[test] fn test_best_quote() { let quotes: Vec = serde_json::from_str(include_str!("./test/chainflip_quotes.json")).unwrap(); diff --git a/core/crates/swapper/src/fees/reserve.rs b/core/crates/swapper/src/fees/reserve.rs index 5dd8c6eb6..93382bf02 100644 --- a/core/crates/swapper/src/fees/reserve.rs +++ b/core/crates/swapper/src/fees/reserve.rs @@ -20,15 +20,10 @@ pub static RESERVED_NATIVE_FEES: LazyLock> = LazyLo (Chain::Solana, "20000"), // 0.00002 SOL (Chain::Ton, "20000000"), // 0.02 TON (Chain::Tron, "20000000"), // 20 TRX - (Chain::Bitcoin, "40000"), // 0.0004 BTC - (Chain::Zcash, "1000000"), // 0.01 ZEC - (Chain::Doge, "500000000"), // 5 DOGE (Chain::Xrp, "2000000"), // 2 XRP (Chain::Cardano, "2000000"), // 2 ADA (Chain::Aptos, "20000000"), // 0.2 APT (Chain::Stellar, "100000"), // 0.01 XLM - (Chain::Litecoin, "100000"), // 0.001 LTC - (Chain::BitcoinCash, "100000"), // 0.001 BCH (Chain::Monad, "5000000000000000"), // 0.005 MON (Chain::XLayer, "5000000000000000"), // 0.005 OKB (Chain::Plasma, "5000000000000000"), // 0.005 XPL @@ -38,6 +33,12 @@ pub static RESERVED_NATIVE_FEES: LazyLock> = LazyLo (Chain::Injective, "1300000000000000"), // 0.0013 INJ (Chain::Sei, "1300000"), // 1.3 SEI (Chain::Noble, "25000"), // 0.025 USDC + // UTXO fee-vs-slippage buffer for amount-sensitive max swaps. + (Chain::Bitcoin, "15000"), // ~300 vB * ~50 sat/vB peak + (Chain::Litecoin, "15000"), // ~300 vB * ~50 lit/vB peak + (Chain::BitcoinCash, "10000"), // ~300 B * ~30 sat/B peak + (Chain::Doge, "10000000"), // 0.1 DOGE + (Chain::Zcash, "30000"), // 3x ZIP-317 marginal fee ]) }); @@ -65,3 +66,31 @@ pub fn quote_value_after_reserve_by_chain(request: &QuoteRequest) -> Result= 12_000, "Bitcoin reserve {reserve} too small to absorb peak-fee planner cost"); + } +} diff --git a/core/crates/swapper/src/mayan/provider.rs b/core/crates/swapper/src/mayan/provider.rs index c09924394..c6b7435e2 100644 --- a/core/crates/swapper/src/mayan/provider.rs +++ b/core/crates/swapper/src/mayan/provider.rs @@ -243,7 +243,7 @@ where #[cfg(test)] mod tests { use super::*; - use crate::mayan::model::{MayanFastMctpQuote, MayanMctpQuote, MayanMonoChainQuote}; + use crate::mayan::model::{MayanFastMctpQuote, MayanMctpQuote}; use crate::models::Options; use crate::{SwapperQuoteAsset, alien::mock::ProviderMock}; use gem_client::testkit::MockClient; @@ -355,10 +355,7 @@ mod tests { #[test] fn test_select_route_prefers_mono_chain_for_hyperevm_to_hypercore() { - let routes = vec![ - MayanQuote::Mctp(Box::new(MayanMctpQuote::mock())), - MayanQuote::MonoChain(Box::new(MayanMonoChainQuote::default())), - ]; + let routes = vec![MayanQuote::Mctp(Box::new(MayanMctpQuote::mock())), MayanQuote::MonoChain(Box::default())]; assert!( Mayan::::select_route(&routes, Chain::Hyperliquid, Chain::HyperCore) diff --git a/core/crates/swapper/src/mayan/testkit.rs b/core/crates/swapper/src/mayan/testkit.rs index 6cf567359..7056a8d5f 100644 --- a/core/crates/swapper/src/mayan/testkit.rs +++ b/core/crates/swapper/src/mayan/testkit.rs @@ -53,7 +53,6 @@ impl MayanFastMctpQuote { referrer_bps: Some(50), expected_amount_out_base_units: Some("900000".to_string()), expected_amount_out: serde_json::json!(0.9), - ..Default::default() }, fast_mctp_input_contract: Some(SOLANA_USDC_TOKEN_ID.to_string()), fast_mctp_mayan_contract: Some(MAYAN_MCTP.to_string()), diff --git a/core/crates/swapper/src/testkit.rs b/core/crates/swapper/src/testkit.rs index 4f2ada401..6b58ad8a9 100644 --- a/core/crates/swapper/src/testkit.rs +++ b/core/crates/swapper/src/testkit.rs @@ -95,6 +95,15 @@ pub fn mock_quote(from_asset: SwapperQuoteAsset, to_asset: SwapperQuoteAsset) -> } } +pub fn mock_bitcoin_max_quote(to_asset: SwapperQuoteAsset) -> QuoteRequest { + let mut request = mock_quote(SwapperQuoteAsset::from(AssetId::from_chain(Chain::Bitcoin)), to_asset); + request.wallet_address = "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4".into(); + request.destination_address = "11111111111111111111111111111111".into(); + request.value = "89100".into(); + request.options.use_max_amount = true; + request +} + pub fn mock_ton(wallet_address: String) -> QuoteRequest { QuoteRequest { from_asset: SwapperQuoteAsset::from(AssetId::from_chain(Chain::Ton)), diff --git a/core/crates/swapper/src/thorchain/asset.rs b/core/crates/swapper/src/thorchain/asset.rs index 171e70c03..e926bad28 100644 --- a/core/crates/swapper/src/thorchain/asset.rs +++ b/core/crates/swapper/src/thorchain/asset.rs @@ -1,7 +1,7 @@ use std::str::FromStr; use num_bigint::BigInt; -use primitives::{Asset, AssetId, Chain}; +use primitives::{Asset, AssetId, BITCOINCASH_PREFIX, Chain}; use super::{THORChainNetwork, chain::ChainName}; @@ -73,7 +73,7 @@ impl THORChainAsset { // https://dev.thorchain.org/concepts/memos.html#swap pub fn swap_memo(&self, asset_name: &str, destination_address: String, minimum: i64, interval: i64, quantity: i64, fee_address: String, bps: u32) -> String { let address = match self.chain.chain() { - Chain::BitcoinCash => destination_address.strip_prefix("bitcoincash:").unwrap_or(&destination_address), + Chain::BitcoinCash => destination_address.strip_prefix(BITCOINCASH_PREFIX).unwrap_or(&destination_address), _ => destination_address.as_str(), }; format!("=:{asset_name}:{address}:{minimum}/{interval}/{quantity}:{fee_address}:{bps}") diff --git a/core/crates/swapper/src/thorchain/provider.rs b/core/crates/swapper/src/thorchain/provider.rs index f7b94fdd8..8a74b7e0f 100644 --- a/core/crates/swapper/src/thorchain/provider.rs +++ b/core/crates/swapper/src/thorchain/provider.rs @@ -201,7 +201,7 @@ mod tests { use std::sync::Arc; use super::*; - use crate::{Options, SwapperQuoteAsset, alien::mock::ProviderMock}; + use crate::{Options, SwapperQuoteAsset, alien::mock::ProviderMock, testkit::mock_bitcoin_max_quote}; use primitives::asset_constants::{ARBITRUM_USDC_ASSET_ID, THORCHAIN_TCY_ASSET_ID}; #[test] @@ -247,6 +247,13 @@ mod tests { .any(|asset| { matches!(asset, SwapperChainAsset::Assets(chain, assets) if *chain == Chain::Thorchain && !assets.contains(&THORCHAIN_TCY_ASSET_ID)) }) ); } + #[test] + fn test_quote_input_value_bitcoin_max_passes_value_through() { + let from_asset = THORChainAsset::from_asset_id(THORChainNetwork::Thorchain, Chain::Bitcoin.as_ref()).unwrap(); + let request = mock_bitcoin_max_quote(SwapperQuoteAsset::from(Chain::Solana.as_asset_id())); + + assert_eq!(quote_input_value(&from_asset, &request).unwrap(), "89100"); + } #[tokio::test] async fn test_get_quote_data_uses_quote_from_value() { diff --git a/core/gemstone/Cargo.toml b/core/gemstone/Cargo.toml index 0faedf8ab..13a36d0d2 100644 --- a/core/gemstone/Cargo.toml +++ b/core/gemstone/Cargo.toml @@ -32,7 +32,7 @@ gem_auth = { path = "../crates/gem_auth" } gem_jsonrpc = { path = "../crates/gem_jsonrpc", features = ["client"] } gem_client = { path = "../crates/gem_client" } gem_hypercore = { path = "../crates/gem_hypercore", features = ["signer"] } -gem_bitcoin = { path = "../crates/gem_bitcoin", features = ["rpc"] } +gem_bitcoin = { path = "../crates/gem_bitcoin", features = ["rpc", "signer"] } gem_cardano = { path = "../crates/gem_cardano", features = ["rpc", "signer"] } gem_algorand = { path = "../crates/gem_algorand", features = ["rpc", "signer"] } gem_stellar = { path = "../crates/gem_stellar", features = ["rpc", "signer"] } diff --git a/core/gemstone/android/gemstone/src/androidTest/java/com/gemwallet/gemstone/GemstoneTest.kt b/core/gemstone/android/gemstone/src/androidTest/java/com/gemwallet/gemstone/GemstoneTest.kt index b5b461350..b74113334 100644 --- a/core/gemstone/android/gemstone/src/androidTest/java/com/gemwallet/gemstone/GemstoneTest.kt +++ b/core/gemstone/android/gemstone/src/androidTest/java/com/gemwallet/gemstone/GemstoneTest.kt @@ -29,20 +29,6 @@ class MockPreferences : GemPreferences { override fun remove(key: String) {} } -sealed class CustomAppException : Exception() { - class DustError(override val message: String) : CustomAppException() -} - -/** - * Mock fee estimator for testing GatewayException.PlatformException. - */ -class MockFeeEstimator( - private val onGetFee: suspend (Chain, GemTransactionLoadInput) -> GemTransactionLoadFee? = { _, _ -> null } -) : GemGatewayEstimateFee { - override suspend fun getFee(chain: Chain, input: GemTransactionLoadInput): GemTransactionLoadFee? = onGetFee(chain, input) - override suspend fun getFeeData(chain: Chain, input: GemTransactionLoadInput): String? = null -} - @RunWith(AndroidJUnit4::class) class GemstoneTest { @@ -96,51 +82,4 @@ class GemstoneTest { } } - /** - * Test 3: Custom exceptions must be wrapped to avoid native crash. - * - * Throwing custom exceptions directly causes native crash - * like UnexpectedUniFFICallbackError(reason: "...BlockchainError$DustError") - * Use GatewayException.PlatformException to wrap custom exceptions, allowing - * clients to distinguish them from network errors. - */ - @Test - fun testFeeEstimatorThrowsPlatformException() = runBlocking { - val errorMessage = "Amount too small" - val feeEstimator = MockFeeEstimator { _, _ -> - // Custom exceptions must be wrapped to avoid native crash - try { - throw CustomAppException.DustError(errorMessage) - } catch (e: CustomAppException) { - throw GatewayException.PlatformException("${e::class.simpleName}: ${e.message}") - } - } - val gateway = createGateway(MockProvider()) - val asset = GemAsset( - id = "ethereum", - chain = "ethereum", - tokenId = null, - name = "Ethereum", - symbol = "ETH", - decimals = 18, - assetType = GemAssetType.NATIVE - ) - val input = GemTransactionLoadInput( - inputType = GemTransactionInputType.Transfer(asset), - senderAddress = "0x1234", - destinationAddress = "0x5678", - value = "1000000000000000000", - gasPrice = GemGasPriceType.Regular("20000000000"), - memo = null, - isMaxValue = false, - metadata = GemTransactionLoadMetadata.None - ) - - try { - gateway.getFee("ethereum", input, feeEstimator) - fail("Expected GatewayException.PlatformException to be thrown") - } catch (e: GatewayException.PlatformException) { - assertTrue(e.msg.contains(errorMessage)) - } - } } diff --git a/core/gemstone/src/address.rs b/core/gemstone/src/address.rs index 4667d9416..a9a32cfac 100644 --- a/core/gemstone/src/address.rs +++ b/core/gemstone/src/address.rs @@ -43,7 +43,7 @@ pub fn validate_address(address: &str, chain: Chain) -> bool { ChainType::Algorand => gem_algorand::validate_address(address), ChainType::Xrp => gem_xrp::validate_address(address), ChainType::Polkadot => gem_polkadot::validate_address(address), - ChainType::Bitcoin => false, + ChainType::Bitcoin => gem_bitcoin::validate_address(address, chain), ChainType::Cardano => gem_cardano::validate_address(address), } } @@ -70,6 +70,10 @@ mod tests { assert!(!validate_address("rnBFvgZphmN39GWzUJeUitaP22Fr9be75J", Chain::Xrp)); assert!(validate_address("15e6w4u9nH4Tb9HdJco2Zua4y5DpHb1hHXBKBGkUrLMTpuXo", Chain::Polkadot)); assert!(!validate_address("15e6w4u9nH4Tb9HdJco2Zua4y5DpHb1hHXBKBGkUrLMTpuXj", Chain::Polkadot)); + assert!(validate_address("bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4", Chain::Bitcoin)); + assert!(!validate_address("bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4", Chain::Litecoin)); + assert!(validate_address("qpzl3jxkzgvfd9flnd26leud5duv795fnv7vuaha70", Chain::BitcoinCash)); + assert!(validate_address("bitcoincash:qpzl3jxkzgvfd9flnd26leud5duv795fnv7vuaha70", Chain::BitcoinCash)); assert!(validate_address( "addr1q8043m5heeaydnvtmmkyuhe6qv5havvhsf0d26q3jygsspxlyfpyk6yqkw0yhtyvtr0flekj84u64az82cufmqn65zdsylzk23", Chain::Cardano diff --git a/core/gemstone/src/gateway/mod.rs b/core/gemstone/src/gateway/mod.rs index 055fed75e..9e4398dd4 100644 --- a/core/gemstone/src/gateway/mod.rs +++ b/core/gemstone/src/gateway/mod.rs @@ -22,13 +22,6 @@ use yielder::Yielder; use primitives::{AssetId, Chain, ChartPeriod, ScanAddressTarget, ScanTransactionPayload, TransactionPreloadInput}; -#[uniffi::export(with_foreign)] -#[async_trait::async_trait] -pub trait GemGatewayEstimateFee: Send + Sync { - async fn get_fee(&self, chain: Chain, input: GemTransactionLoadInput) -> Result, GatewayError>; - async fn get_fee_data(&self, chain: Chain, input: GemTransactionLoadInput) -> Result, GatewayError>; -} - #[derive(uniffi::Object)] pub struct GemGateway { pub api_client: GemApiClient, @@ -54,17 +47,6 @@ impl GemGateway { } } -#[async_trait::async_trait] -impl GemGatewayEstimateFee for GemGateway { - async fn get_fee(&self, _chain: Chain, _input: GemTransactionLoadInput) -> Result, GatewayError> { - Ok(None) - } - - async fn get_fee_data(&self, _chain: Chain, _input: GemTransactionLoadInput) -> Result, GatewayError> { - Ok(None) - } -} - #[uniffi::export] impl GemGateway { #[uniffi::constructor] @@ -172,32 +154,14 @@ impl GemGateway { self.api_client.scan_transaction(payload).await.map(Some).map_err(|e| GatewayError::NetworkError { msg: e }) } - pub async fn get_fee(&self, chain: Chain, input: GemTransactionLoadInput, provider: Arc) -> Result, GatewayError> { - let fee = provider.get_fee(chain, input.clone()).await?; - if let Some(fee) = fee { - return Ok(Some(fee)); - } - if let Some(fee_data) = provider.get_fee_data(chain, input.clone()).await? { - let data = self - .with_provider(chain, |chain_provider| async move { chain_provider.get_transaction_fee_from_data(fee_data).await }) - .await?; - return Ok(Some(data.into())); - } - Ok(None) - } - - pub async fn get_transaction_load(&self, chain: Chain, input: GemTransactionLoadInput, provider: Arc) -> Result { - let fee = self.get_fee(chain, input.clone(), provider.clone()).await?; - + pub async fn get_transaction_load(&self, chain: Chain, input: GemTransactionLoadInput) -> Result { let load_data = self - .with_provider(chain, |chain_provider| async move { chain_provider.get_transaction_load(input.clone().into()).await }) + .with_provider(chain, |chain_provider| async move { chain_provider.get_transaction_load(input.into()).await }) .await?; - let data = if let Some(fee) = fee { load_data.new_from(fee.into()) } else { load_data }; - Ok(GemTransactionData { - fee: data.fee.into(), - metadata: data.metadata.into(), + fee: load_data.fee.into(), + metadata: load_data.metadata.into(), }) } diff --git a/core/gemstone/src/signer/chain.rs b/core/gemstone/src/signer/chain.rs index 397cc89ca..2297b251a 100644 --- a/core/gemstone/src/signer/chain.rs +++ b/core/gemstone/src/signer/chain.rs @@ -1,6 +1,7 @@ use crate::{GemstoneError, models::transaction::GemSignerInput}; use gem_algorand::AlgorandChainSigner; use gem_aptos::AptosChainSigner; +use gem_bitcoin::signer::BitcoinChainSigner; use gem_cardano::signer::CardanoChainSigner; use gem_cosmos::signer::CosmosChainSigner; use gem_evm::signer::EvmChainSigner; @@ -13,7 +14,7 @@ use gem_sui::signer::SuiChainSigner; use gem_ton::signer::TonChainSigner; use gem_tron::signer::TronChainSigner; use gem_xrp::signer::XrpChainSigner; -use primitives::{Chain, ChainSigner, ChainType, EVMChain, SignerError, SignerInput}; +use primitives::{BitcoinChain, Chain, ChainSigner, ChainType, EVMChain, SignerError, SignerInput}; use zeroize::Zeroizing; #[derive(uniffi::Object)] @@ -41,7 +42,7 @@ impl GemChainSigner { ChainType::Xrp => Box::new(XrpChainSigner), ChainType::Polkadot => Box::new(PolkadotChainSigner), ChainType::Cardano => Box::new(CardanoChainSigner), - _ => todo!("Signer not implemented for chain {:?}", chain), + ChainType::Bitcoin => Box::new(BitcoinChainSigner::new(BitcoinChain::from_chain(chain).unwrap())), }; Self { chain, signer } diff --git a/core/skills/error-handling.md b/core/skills/error-handling.md index fded376be..445a8b629 100644 --- a/core/skills/error-handling.md +++ b/core/skills/error-handling.md @@ -79,6 +79,51 @@ let data = serde_json::from_str(input) let data: MyStruct = serde_json::from_str(input)?; ``` +## Don't Let Error Plumbing Bury the Logic + +When a block does repeated validation, inline `.ok_or_else(|| Error::invalid_input(format!("{ctx} ...")))?` makes every line read as error handling instead of logic. Capture the shared context in a local closure, and use guard clauses for boolean checks instead of `cond.then_some(()).ok_or_else(...)?`. + +```rust +// bad — the chain/format context repeats on every line and drowns the logic +let (unlocking_script, hash) = address + .unlocking_script() + .zip(address.public_key_hash()) + .ok_or_else(|| SignerError::invalid_input(format!("{} UTXO address type is unsupported", chain.get_chain())))?; +(hash == sender_hash) + .then_some(()) + .ok_or_else(|| SignerError::invalid_input(format!("{} UTXO address does not match sender", chain.get_chain())))?; +let vout = u32::try_from(utxo.vout).map_err(|_| SignerError::invalid_input(format!("invalid {} UTXO index", chain.get_chain())))?; + +// good — build the chain-scoped message once; pick the constructor by context +let message = |reason: &str| format!("{} {reason}", chain.get_chain()); + +let (unlocking_script, hash) = address + .unlocking_script() + .zip(address.public_key_hash()) + .ok_or_else(|| SignerError::invalid_input(message("UTXO address type is unsupported")))?; +if hash != sender_hash { + return SignerError::invalid_input_err(message("UTXO address does not match sender")); +} +let vout = u32::try_from(utxo.vout).map_err(|_| SignerError::invalid_input(message("UTXO index is invalid")))?; +``` + +Use the `_err` Result-returning constructor (`invalid_input_err`) for guard-clause early returns; use the bare `invalid_input` inside `ok_or_else`/`map_err` combinators. Don't hand-roll `return Err(SignerError::invalid_input(...))` when an `_err` constructor exists. + +## Don't Repeat a Message for an Unreachable Branch + +If an earlier guard already bounds a value, a later "can't fail" conversion must not duplicate the guard's message — surface the real library error so a future limit change isn't masked by a stale, wrong message. + +```rust +// bad — at <=80 bytes PushBytes never overflows, so this message is unreachable and misleading +if data.len() > MAX_OP_RETURN_BYTES { + return SignerError::invalid_input_err("Bitcoin memo is too large"); +} +let push = PushBytesBuf::try_from(data.to_vec()).map_err(|_| SignerError::invalid_input("Bitcoin memo is too large"))?; + +// good — the length check owns the rule; the conversion surfaces the actual error if it ever fires +let push = PushBytesBuf::try_from(data.to_vec()).map_err(SignerError::from_display)?; +``` + ## JSON Parameter Extraction Use the `primitives::ValueAccess` trait instead of manual `.get().ok_or()` chains: diff --git a/ios/Features/Transfer/Sources/Types/ChainCoreError+Localizations.swift b/ios/Features/Transfer/Sources/Types/ChainCoreError+Localizations.swift index a0137581b..b26632b6f 100644 --- a/ios/Features/Transfer/Sources/Types/ChainCoreError+Localizations.swift +++ b/ios/Features/Transfer/Sources/Types/ChainCoreError+Localizations.swift @@ -10,6 +10,7 @@ extension ChainCoreError: @retroactive LocalizedError { case .cantEstimateFee, .feeRateMissed: Localized.Errors.unableEstimateNetworkFee case .incorrectAmount: Localized.Errors.invalidAmount case .dustThreshold: Localized.Errors.dustThresholdShort + case .insufficientBalance: Localized.Info.InsufficientBalance.title } } } diff --git a/ios/Features/Transfer/Sources/ViewModels/ConfirmTransferSceneViewModel.swift b/ios/Features/Transfer/Sources/ViewModels/ConfirmTransferSceneViewModel.swift index dff50a5c2..0deb3c3b9 100644 --- a/ios/Features/Transfer/Sources/ViewModels/ConfirmTransferSceneViewModel.swift +++ b/ios/Features/Transfer/Sources/ViewModels/ConfirmTransferSceneViewModel.swift @@ -290,7 +290,7 @@ extension ConfirmTransferSceneViewModel { case .dustThreshold: let asset = dataModel.asset isPresentingSheet = .info(.dustThreshold(asset.chain, image: AssetViewModel(asset: asset).assetImage)) - case .feeRateMissed, .cantEstimateFee, .incorrectAmount: + case .feeRateMissed, .cantEstimateFee, .incorrectAmount, .insufficientBalance: break } } diff --git a/ios/Packages/Blockchain/Package.swift b/ios/Packages/Blockchain/Package.swift index 185116704..d5a6bbae3 100644 --- a/ios/Packages/Blockchain/Package.swift +++ b/ios/Packages/Blockchain/Package.swift @@ -18,7 +18,6 @@ let package = Package( dependencies: [ .package(name: "Primitives", path: "../Primitives"), .package(name: "SwiftHTTPClient", path: "../SwiftHTTPClient"), - .package(name: "WalletCore", path: "../WalletCore"), .package(name: "Gemstone", path: "../Gemstone"), .package(name: "GemstonePrimitives", path: "../GemstonePrimitives"), .package(name: "Keychain", path: "../Keychain"), @@ -30,8 +29,6 @@ let package = Package( dependencies: [ "SwiftHTTPClient", "Primitives", - .product(name: "WalletCore", package: "WalletCore"), - .product(name: "WalletCorePrimitives", package: "WalletCore"), "Gemstone", "GemstonePrimitives", "Keychain", diff --git a/ios/Packages/Blockchain/Sources/ChainCoreError.swift b/ios/Packages/Blockchain/Sources/ChainCoreError.swift index 6f4fbb413..fb426b619 100644 --- a/ios/Packages/Blockchain/Sources/ChainCoreError.swift +++ b/ios/Packages/Blockchain/Sources/ChainCoreError.swift @@ -3,31 +3,24 @@ import Foundation import Gemstone import Primitives -import WalletCore public enum ChainCoreError: String, Error, Equatable { case feeRateMissed case cantEstimateFee case incorrectAmount case dustThreshold + case insufficientBalance - static func fromWalletCore(_ error: CommonSigningError) throws { - let chainError: ChainCoreError? = switch error { - case .errorDustAmountRequested, - .errorNotEnoughUtxos, - .errorMissingInputUtxos: ChainCoreError.dustThreshold - case .ok: .none - default: ChainCoreError.cantEstimateFee + public static func fromError(_ error: Error) -> ChainCoreError? { + let description = error.localizedDescription + if description.contains("dust threshold") { + return .dustThreshold } - - if let error = chainError { - throw error + if description.contains("insufficient balance") { + return .insufficientBalance } - } - - public static func fromError(_ error: Error) -> ChainCoreError? { - for errorCase in [ChainCoreError.dustThreshold, .feeRateMissed, .cantEstimateFee, .incorrectAmount] { - if error.localizedDescription.contains(errorCase.rawValue) { + for errorCase in [ChainCoreError.feeRateMissed, .cantEstimateFee, .incorrectAmount] { + if description.contains(errorCase.rawValue) { return errorCase } } diff --git a/ios/Packages/Blockchain/Sources/EstimateFeeService/BitcoinService.swift b/ios/Packages/Blockchain/Sources/EstimateFeeService/BitcoinService.swift deleted file mode 100644 index 426c35f92..000000000 --- a/ios/Packages/Blockchain/Sources/EstimateFeeService/BitcoinService.swift +++ /dev/null @@ -1,88 +0,0 @@ -// Copyright (c). Gem Wallet. All rights reserved. - -import BigInt -import Foundation -import Gemstone -import Primitives -import SwiftHTTPClient -import WalletCore - -internal import GemstonePrimitives -internal import WalletCorePrimitives - -final class BitcoinService: Sendable { - let chain: BitcoinChain - - init( - chain: BitcoinChain, - ) { - self.chain = chain - } - - func calculate( - senderAddress: String, - destinationAddress: String, - amount: BigInt, - isMaxAmount: Bool, - gasPrice: BigInt, - utxos: [UTXO], - ) throws -> Fee { - guard amount <= BigInt(Int64.max) else { - throw ChainCoreError.incorrectAmount - } - - guard !utxos.isEmpty else { - throw ChainCoreError.cantEstimateFee - } - - let primitiveChain = chain.chain - let coinType = primitiveChain.coinType - - let utxo = utxos.map { $0.mapToUnspendTransaction(address: senderAddress, coinType: coinType) } - let scripts = utxo.mapToScripts(address: senderAddress, coinType: coinType) - let hashType = BitcoinScript.hashTypeForCoin(coinType: coinType) - - let signingInput = BitcoinSigningInput.with { - $0.coinType = coinType.rawValue - $0.hashType = hashType - $0.amount = amount.asInt64 - $0.byteFee = gasPrice.asInt64 - $0.toAddress = destinationAddress - $0.changeAddress = senderAddress - $0.utxo = utxo - $0.scripts = scripts - $0.useMaxAmount = isMaxAmount - $0.zip0317 = false - } - let plan: BitcoinTransactionPlan = AnySigner.plan(input: signingInput, coin: coinType) - - try ChainCoreError.fromWalletCore(plan.error) - - return Fee( - fee: BigInt(plan.fee), - gasPriceType: .regular(gasPrice: gasPrice), - gasLimit: 1, - ) - } - - func calculateFee(input: TransactionInput) throws -> Fee { - try calculate( - senderAddress: input.senderAddress, - destinationAddress: input.destinationAddress, - amount: input.value, - isMaxAmount: input.feeInput.isMaxAmount, - gasPrice: input.gasPrice.gasPrice, - utxos: input.metadata.getUtxos(), - ) - } -} - -extension BitcoinService: GemGatewayEstimateFee { - func getFee(chain _: Gemstone.Chain, input: Gemstone.GemTransactionLoadInput) async throws -> Gemstone.GemTransactionLoadFee? { - try calculateFee(input: input.map()).map() - } - - func getFeeData(chain _: Gemstone.Chain, input _: GemTransactionLoadInput) async throws -> String? { - .none - } -} diff --git a/ios/Packages/Blockchain/Sources/EstimateFeeService/EstimateFeeService.swift b/ios/Packages/Blockchain/Sources/EstimateFeeService/EstimateFeeService.swift deleted file mode 100644 index 4aebc0b4b..000000000 --- a/ios/Packages/Blockchain/Sources/EstimateFeeService/EstimateFeeService.swift +++ /dev/null @@ -1,38 +0,0 @@ -// Copyright (c). Gem Wallet. All rights reserved. - -import Foundation -import Gemstone -import Primitives - -struct EstimateFeeService { - init() {} - - func provider(chain: Primitives.Chain) throws -> any GemGatewayEstimateFee { - switch chain.type { - case .bitcoin: try BitcoinService(chain: BitcoinChain(id: chain.rawValue)) - default: EmptyService() - } - } - - func getFee(chain: Gemstone.Chain, input: Gemstone.GemTransactionLoadInput) async throws -> Gemstone.GemTransactionLoadFee? { - try await provider(chain: chain.map()).getFee(chain: chain, input: input) - } - - func getFeeData(chain: Gemstone.Chain, input: Gemstone.GemTransactionLoadInput) async throws -> String? { - try await provider(chain: chain.map()).getFeeData(chain: chain, input: input) - } -} - -final class EmptyService: Sendable { - init() {} -} - -extension EmptyService: GemGatewayEstimateFee { - func getFee(chain _: Gemstone.Chain, input _: Gemstone.GemTransactionLoadInput) async throws -> Gemstone.GemTransactionLoadFee? { - .none - } - - func getFeeData(chain _: Gemstone.Chain, input _: GemTransactionLoadInput) async throws -> String? { - .none - } -} diff --git a/ios/Packages/Blockchain/Sources/Gateway/GatewayService.swift b/ios/Packages/Blockchain/Sources/Gateway/GatewayService.swift index 3a0fd30c5..95bc934db 100644 --- a/ios/Packages/Blockchain/Sources/Gateway/GatewayService.swift +++ b/ios/Packages/Blockchain/Sources/Gateway/GatewayService.swift @@ -23,16 +23,6 @@ public actor GatewayService: Sendable { } } -extension GatewayService: GemGatewayEstimateFee { - public func getFee(chain: Gemstone.Chain, input: Gemstone.GemTransactionLoadInput) async throws -> Gemstone.GemTransactionLoadFee? { - try await EstimateFeeService().getFee(chain: chain, input: input) - } - - public func getFeeData(chain: Gemstone.Chain, input: GemTransactionLoadInput) async throws -> String? { - try await EstimateFeeService().getFeeData(chain: chain, input: input) - } -} - // MARK: - Balances public extension GatewayService { @@ -128,7 +118,7 @@ public extension GatewayService { } func transactionLoad(chain: Primitives.Chain, input: GemTransactionLoadInput) async throws -> TransactionData { - try await gateway.getTransactionLoad(chain: chain.rawValue, input: input, provider: self).map() + try await gateway.getTransactionLoad(chain: chain.rawValue, input: input).map() } } diff --git a/ios/Packages/Blockchain/Tests/BlockchainTests/BitcoinFeeCalculatorTests.swift b/ios/Packages/Blockchain/Tests/BlockchainTests/BitcoinFeeCalculatorTests.swift deleted file mode 100644 index 2250ccf03..000000000 --- a/ios/Packages/Blockchain/Tests/BlockchainTests/BitcoinFeeCalculatorTests.swift +++ /dev/null @@ -1,101 +0,0 @@ -// Copyright (c). Gem Wallet. All rights reserved. - -import BigInt -@testable import Blockchain -import Foundation -import Primitives -import PrimitivesTestKit -import Testing -import WalletCore - -struct BitcoinServiceTests { - let utxos: [UTXO] = [ - UTXO( - transaction_id: "21d603f2bcf5bf3c9b653ec70f53cf6caf9ad51d304e9fbbf609832d1f9a1fec", - vout: 0, - value: "1100", - address: "", - ), - UTXO(transaction_id: "e2751b44a4eee26cad70c6f3d41ada51def4765c209de97b422b1c6d05c0ac6e", vout: 1, value: "104810", address: ""), - ] - - let feeRates: [FeeRate] = [ - FeeRate(priority: .fast, gasPriceType: .regular(gasPrice: 5647)), - FeeRate(priority: .slow, gasPriceType: .regular(gasPrice: 3831)), - FeeRate(priority: .normal, gasPriceType: .regular(gasPrice: 4974)), - ] - - let chain = BitcoinChain.bitcoin - - var feeInput: FeeInput { - FeeInput( - type: .transfer(.init(.bitcoin)), - senderAddress: "bc1qgxe8qnqpuz0zqtgztxl77t5egf2xzh2l4ylx90", - destinationAddress: "bc1qhgxl7yjhaazdhrfh0tzge572wkyp43h7t64fal", - value: BigInt(105_910), - balance: BigInt(105_910), - gasPrice: feeRates[2].gasPriceType, - memo: nil, - ) - } - - @Test - func calculateFeeSuccess() throws { - let service = BitcoinService(chain: chain) - let fee = try service.calculate( - senderAddress: feeInput.senderAddress, - destinationAddress: feeInput.destinationAddress, - amount: BigInt(1000), - isMaxAmount: feeInput.isMaxAmount, - gasPrice: BigInt(10), - utxos: utxos, - ) - - let targetFee = Fee( - fee: BigInt(1780), - gasPriceType: .regular(gasPrice: BigInt(10)), - gasLimit: 1, - ) - #expect(fee == targetFee) - } - - @Test - func calculateFeeIncorrectAmount() throws { - let incorrectAmountFeeInput = FeeInput( - type: feeInput.type, - senderAddress: feeInput.senderAddress, - destinationAddress: feeInput.destinationAddress, - value: BigInt(Int64.max) + 1, // Incorrect amount, too large - balance: feeInput.balance, - gasPrice: feeInput.gasPrice, - memo: feeInput.memo, - ) - - #expect(throws: ChainCoreError.incorrectAmount) { - let service = BitcoinService(chain: chain) - _ = try service.calculate( - senderAddress: incorrectAmountFeeInput.senderAddress, - destinationAddress: incorrectAmountFeeInput.destinationAddress, - amount: incorrectAmountFeeInput.value, - isMaxAmount: incorrectAmountFeeInput.isMaxAmount, - gasPrice: incorrectAmountFeeInput.gasPrice.gasPrice, - utxos: utxos, - ) - } - } - - @Test - func calculateFeeCantEstimateFee() throws { - #expect(throws: ChainCoreError.cantEstimateFee) { - let service = BitcoinService(chain: chain) - _ = try service.calculate( - senderAddress: feeInput.senderAddress, - destinationAddress: feeInput.destinationAddress, - amount: feeInput.value, - isMaxAmount: feeInput.isMaxAmount, - gasPrice: feeInput.gasPrice.gasPrice, - utxos: [], - ) - } - } -} diff --git a/ios/Packages/Blockchain/Tests/BlockchainTests/ChainCoreErrorTests.swift b/ios/Packages/Blockchain/Tests/BlockchainTests/ChainCoreErrorTests.swift new file mode 100644 index 000000000..9fe6c6f5b --- /dev/null +++ b/ios/Packages/Blockchain/Tests/BlockchainTests/ChainCoreErrorTests.swift @@ -0,0 +1,30 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +@testable import Blockchain +import Foundation +import Testing + +struct ChainCoreErrorTests { + private struct StubError: LocalizedError { + let message: String + var errorDescription: String? { message } + } + + @Test + func fromErrorMapsNativeSignerDustMessage() { + let error = StubError(message: "transaction amount is below the dust threshold") + #expect(ChainCoreError.fromError(error) == .dustThreshold) + } + + @Test + func fromErrorMapsNativeSignerInsufficientBalanceMessage() { + let error = StubError(message: "insufficient balance") + #expect(ChainCoreError.fromError(error) == .insufficientBalance) + } + + @Test + func fromErrorReturnsNilForUnrelatedMessage() { + let error = StubError(message: "broadcast failed: node offline") + #expect(ChainCoreError.fromError(error) == nil) + } +} diff --git a/ios/Packages/GemstonePrimitives/Sources/Extensions/Chain+GemstoneSwift.swift b/ios/Packages/GemstonePrimitives/Sources/Extensions/Chain+GemstoneSwift.swift index 0b48970ef..c8f6f91a4 100644 --- a/ios/Packages/GemstonePrimitives/Sources/Extensions/Chain+GemstoneSwift.swift +++ b/ios/Packages/GemstonePrimitives/Sources/Extensions/Chain+GemstoneSwift.swift @@ -94,7 +94,7 @@ public extension Primitives.Chain { } } - func isValidAddress(address: String) -> Bool { + func isValidAddress(_ address: String) -> Bool { Gemstone.validateAddress(address: address, chain: rawValue) } } diff --git a/ios/Packages/GemstonePrimitives/Tests/GemstonePrimitivesTests/ChainTests.swift b/ios/Packages/GemstonePrimitives/Tests/GemstonePrimitivesTests/ChainTests.swift index c02489f60..91d71cb61 100644 --- a/ios/Packages/GemstonePrimitives/Tests/GemstonePrimitivesTests/ChainTests.swift +++ b/ios/Packages/GemstonePrimitives/Tests/GemstonePrimitivesTests/ChainTests.swift @@ -16,4 +16,12 @@ final class ChainTests { #expect(Chain.ethereum.transactionTimeoutSeconds == 1440) #expect(Chain.solana.transactionTimeoutSeconds == 75) } + + @Test + func addressValidation() { + #expect(Chain.ethereum.isValidAddress("0x95222290DD7278Aa3Ddd389Cc1E1d165CC4BAfe5")) + #expect(!Chain.ethereum.isValidAddress("0x123")) + #expect(Chain.cardano.isValidAddress("addr1q8043m5heeaydnvtmmkyuhe6qv5havvhsf0d26q3jygsspxlyfpyk6yqkw0yhtyvtr0flekj84u64az82cufmqn65zdsylzk23")) + #expect(!Chain.cardano.isValidAddress("addr1q8043m5heeaydnvtmmkyuhe6qv5havvhsf0d26q3jygsspxlyfpyk6yqkw0yhtyvtr0flekj84u64az82cufmqn65zdsylzk2x")) + } } diff --git a/ios/Packages/Signer/Package.swift b/ios/Packages/Signer/Package.swift index 37f08550b..df4fb0ed8 100644 --- a/ios/Packages/Signer/Package.swift +++ b/ios/Packages/Signer/Package.swift @@ -18,7 +18,6 @@ let package = Package( dependencies: [ .package(name: "Primitives", path: "../Primitives"), .package(name: "Keystore", path: "../Keystore"), - .package(name: "WalletCore", path: "../WalletCore"), .package(name: "Gemstone", path: "../Gemstone"), .package(name: "GemstonePrimitives", path: "../GemstonePrimitives"), ], @@ -28,7 +27,6 @@ let package = Package( dependencies: [ "Primitives", "Keystore", - .product(name: "WalletCore", package: "WalletCore"), "Gemstone", "GemstonePrimitives", ], diff --git a/ios/Packages/Signer/Sources/Chains/BitcoinSigner.swift b/ios/Packages/Signer/Sources/Chains/BitcoinSigner.swift deleted file mode 100644 index e936144c9..000000000 --- a/ios/Packages/Signer/Sources/Chains/BitcoinSigner.swift +++ /dev/null @@ -1,122 +0,0 @@ -// Copyright (c). Gem Wallet. All rights reserved. - -import Foundation -import Primitives -import WalletCore - -struct BitcoinSigner: Signable { - func signTransfer(input: SignerInput, privateKey: Data) throws -> String { - try sign(input: input, privateKey: privateKey) - } - - func signTokenTransfer(input _: SignerInput, privateKey _: Data) throws -> String { - fatalError() - } - - func signData(input _: Primitives.SignerInput, privateKey _: Data) throws -> String { - fatalError() - } - - func sign(input: SignerInput, privateKey: Data) throws -> String { - let coinType = input.coinType - let utxos = try input.metadata.getUtxos().map { - $0.mapToUnspendTransaction(address: input.senderAddress, coinType: coinType) - } - - if coinType == .zcash { - return try signZcash( - input: input, - coinType: coinType, - privateKey: privateKey, - utxos: utxos, - ) - } - - let signingInput = try BitcoinSigningInput.with { - $0.coinType = coinType.rawValue - $0.hashType = BitcoinScript.hashTypeForCoin(coinType: coinType) - $0.amount = input.value.asInt64 - $0.byteFee = input.fee.gasPrice.asInt64 - $0.toAddress = input.destinationAddress - $0.changeAddress = input.senderAddress - $0.utxo = utxos - $0.privateKey = [privateKey] - if let memo = input.memo { - $0.outputOpReturn = try memo.encodedData() - } - $0.scripts = utxos.mapToScripts(address: input.senderAddress, coinType: coinType) - $0.useMaxAmount = input.useMaxAmount - } - - return try performSigning(signingInput: signingInput, coinType: coinType) - } - - private func signZcash( - input: SignerInput, - coinType: CoinType, - privateKey: Data, - utxos: [BitcoinUnspentTransaction], - ) throws -> String { - guard case let .zcash(_, branchId) = input.metadata else { - throw AnyError("invalid zcash metadata") - } - var signingInput = BitcoinSigningInput.with { - $0.coinType = coinType.rawValue - $0.hashType = BitcoinScript.hashTypeForCoin(coinType: coinType) - $0.amount = input.value.asInt64 - $0.byteFee = 0 - $0.toAddress = input.destinationAddress - $0.changeAddress = input.senderAddress - $0.utxo = utxos - $0.privateKey = [privateKey] - $0.scripts = utxos.mapToScripts(address: input.senderAddress, coinType: coinType) - $0.useMaxAmount = input.useMaxAmount - } - - let totalAvailable = utxos.reduce(into: Int64(0)) { result, utxo in - result += utxo.amount - } - - let fee = input.fee.fee.asInt64 - - guard totalAvailable >= fee else { - throw AnyError("Insufficient balance to cover Zcash fee") - } - - let requestedAmount = signingInput.amount - let targetAmount = signingInput.useMaxAmount ? max(totalAvailable - fee, 0) : requestedAmount - - guard totalAvailable - fee >= targetAmount else { - throw AnyError("Insufficient balance to cover Zcash amount and fee") - } - - let change = max(totalAvailable - targetAmount - fee, 0) - - signingInput.amount = targetAmount - signingInput.zip0317 = false - signingInput.plan = try BitcoinTransactionPlan.with { - $0.amount = targetAmount - $0.availableAmount = totalAvailable - $0.fee = fee - $0.change = change - $0.utxos = utxos - $0.branchID = try Data(Data.from(hex: branchId).reversed()) - } - - return try performSigning(signingInput: signingInput, coinType: coinType) - } - - private func performSigning(signingInput: BitcoinSigningInput, coinType: CoinType) throws -> String { - let output: BitcoinSigningOutput = AnySigner.sign(input: signingInput, coin: coinType) - - if output.error != .ok { - throw AnyError("\(output.error)") - } - - if !output.errorMessage.isEmpty { - throw AnyError(output.errorMessage) - } - - return output.encoded.hexString - } -} diff --git a/ios/Packages/Signer/Sources/Signer.swift b/ios/Packages/Signer/Sources/Signer.swift index cc2340a02..a099e6b6f 100644 --- a/ios/Packages/Signer/Sources/Signer.swift +++ b/ios/Packages/Signer/Sources/Signer.swift @@ -72,9 +72,6 @@ public struct Signer: Sendable { } func signer(for chain: Chain) -> Signable { - switch chain.type { - case .ethereum, .solana, .sui, .hyperCore, .aptos, .near, .stellar, .algorand, .ton, .cosmos, .xrp, .polkadot, .cardano, .tron: ChainSigner(chain: chain) - case .bitcoin: BitcoinSigner() - } + ChainSigner(chain: chain) } } diff --git a/ios/Packages/Signer/Sources/SwapSigner.swift b/ios/Packages/Signer/Sources/SwapSigner.swift index 99aac27aa..cbb26ef62 100644 --- a/ios/Packages/Signer/Sources/SwapSigner.swift +++ b/ios/Packages/Signer/Sources/SwapSigner.swift @@ -3,7 +3,6 @@ import Foundation import Keystore import Primitives -import WalletCore struct SwapSigner { init() {} diff --git a/ios/Packages/Signer/Tests/SignerTests/SignerTests.swift b/ios/Packages/Signer/Tests/SignerTests/SignerTests.swift index 8d378514d..153a2ee5d 100644 --- a/ios/Packages/Signer/Tests/SignerTests/SignerTests.swift +++ b/ios/Packages/Signer/Tests/SignerTests/SignerTests.swift @@ -16,4 +16,11 @@ struct SignerTests { #expect(type(of: signer) == ChainSigner.self) } + + @Test + func bitcoinUsesChainSigner() { + let signer = Signer(wallet: .mock(), keystore: LocalKeystore.mock()).signer(for: .bitcoin) + + #expect(type(of: signer) == ChainSigner.self) + } } diff --git a/ios/Packages/Signer/Tests/SignerTests/SuiSignerTest.swift b/ios/Packages/Signer/Tests/SignerTests/SuiSignerTest.swift deleted file mode 100644 index fdb13d19a..000000000 --- a/ios/Packages/Signer/Tests/SignerTests/SuiSignerTest.swift +++ /dev/null @@ -1,17 +0,0 @@ -// Copyright (c). Gem Wallet. All rights reserved. - -import Foundation -@testable import Signer -import Testing -import WalletCore - -struct SuiSignerTest { - @Test - func testHash() throws { - let txData = try #require(Data(hexString: "000000000003000800ca9a3b0000000001010000000000000000000000000000000000000000000000000000000000000005010000000000000001002061953ea72709eed72f4441dd944eec49a11b4acabfc8e04015e89c63be81b6ab020200010100000000000000000000000000000000000000000000000000000000000000000000030a7375695f73797374656d11726571756573745f6164645f7374616b6500030101000300000000010200e6af80fe1b0b42fcd96762e5c70f5e8dae39f8f0ee0f118cac0d55b74e2927c20136b8380aa7531d73723657d73a114cfafedf89dc8c76b6752f6daef17e43dda2e5d8f4030000000020f71f24516bc04cbf877d42faf459514448c8de6cff48faa44b3eef3b26782e8fe6af80fe1b0b42fcd96762e5c70f5e8dae39f8f0ee0f118cac0d55b74e2927c2ee02000000000000002d31010000000000")) - let expected = "66be75b0f86ca3a9f24380adc8d8336d8921d5dbdc78f1b3c24c7d6842ce5911" - - let hash = Hash.blake2b(data: txData, size: 32) - #expect(hash.hexString == expected) - } -} diff --git a/ios/Packages/Validators/Package.swift b/ios/Packages/Validators/Package.swift index d129056ac..65be58e5a 100644 --- a/ios/Packages/Validators/Package.swift +++ b/ios/Packages/Validators/Package.swift @@ -16,8 +16,8 @@ let package = Package( ], dependencies: [ .package(name: "Primitives", path: "../Primitives"), + .package(name: "GemstonePrimitives", path: "../GemstonePrimitives"), .package(name: "Localization", path: "../Localization"), - .package(name: "WalletCore", path: "../WalletCore"), .package(name: "Formatters", path: "../Formatters"), ], targets: [ @@ -25,8 +25,8 @@ let package = Package( name: "Validators", dependencies: [ "Primitives", + "GemstonePrimitives", "Localization", - .product(name: "WalletCorePrimitives", package: "WalletCore"), "Formatters", ], path: "Sources", diff --git a/ios/Packages/Validators/Sources/Validators/AddressTextValidator.swift b/ios/Packages/Validators/Sources/Validators/AddressTextValidator.swift index 4b334ccfa..428c57898 100644 --- a/ios/Packages/Validators/Sources/Validators/AddressTextValidator.swift +++ b/ios/Packages/Validators/Sources/Validators/AddressTextValidator.swift @@ -1,10 +1,9 @@ // Copyright (c). Gem Wallet. All rights reserved. import Foundation +import GemstonePrimitives import Primitives -internal import WalletCorePrimitives - public struct AddressTextValidator: TextValidator { private let asset: Asset diff --git a/ios/Packages/WalletCore/Sources/WalletCorePrimitives/Base58+WalletCorePrimitives.swift b/ios/Packages/WalletCore/Sources/WalletCorePrimitives/Base58+WalletCorePrimitives.swift deleted file mode 100644 index 1aacf9871..000000000 --- a/ios/Packages/WalletCore/Sources/WalletCorePrimitives/Base58+WalletCorePrimitives.swift +++ /dev/null @@ -1,21 +0,0 @@ -// Copyright (c). Gem Wallet. All rights reserved. - -import Foundation -import Primitives -import WalletCore - -public extension Base58 { - static func decodeNoCheck(string: String) throws -> Data { - guard let data = Base58.decodeNoCheck(string: string) else { - throw AnyError("Invalid base64 string") - } - return data - } - - static func decode(string: String) throws -> Data { - guard let data = Base58.decode(string: string) else { - throw AnyError("Invalid base64 string") - } - return data - } -} diff --git a/ios/Packages/WalletCore/Sources/WalletCorePrimitives/Chain+WalletCorePrimitives.swift b/ios/Packages/WalletCore/Sources/WalletCorePrimitives/Chain+WalletCorePrimitives.swift index 892e33036..1c4caaaa6 100644 --- a/ios/Packages/WalletCore/Sources/WalletCorePrimitives/Chain+WalletCorePrimitives.swift +++ b/ios/Packages/WalletCore/Sources/WalletCorePrimitives/Chain+WalletCorePrimitives.swift @@ -63,10 +63,6 @@ public extension Chain { } } - func isValidAddress(_ address: String) -> Bool { - AnyAddress.isValid(string: address, coin: coinType) - } - func checksumAddress(_ address: String) -> String { if let chain = EVMChain(rawValue: rawValue), let address = AnyAddress(string: address, coin: chain.chain.coinType) { return address.description diff --git a/ios/Packages/WalletCore/Sources/WalletCorePrimitives/SignerInput+WalletCorePrimitives.swift b/ios/Packages/WalletCore/Sources/WalletCorePrimitives/SignerInput+WalletCorePrimitives.swift deleted file mode 100644 index 0f08dcadf..000000000 --- a/ios/Packages/WalletCore/Sources/WalletCorePrimitives/SignerInput+WalletCorePrimitives.swift +++ /dev/null @@ -1,11 +0,0 @@ -// Copyright (c). Gem Wallet. All rights reserved. - -import Foundation -import Primitives -import WalletCore - -public extension SignerInput { - var coinType: WalletCore.CoinType { - asset.chain.coinType - } -} diff --git a/ios/Packages/WalletCore/Sources/WalletCorePrimitives/UTXO+WalletCorePrimitives.swift b/ios/Packages/WalletCore/Sources/WalletCorePrimitives/UTXO+WalletCorePrimitives.swift deleted file mode 100644 index de1a6c9a2..000000000 --- a/ios/Packages/WalletCore/Sources/WalletCorePrimitives/UTXO+WalletCorePrimitives.swift +++ /dev/null @@ -1,33 +0,0 @@ -// Copyright (c). Gem Wallet. All rights reserved. - -import Foundation -import Primitives -import WalletCore - -public extension UTXO { - func mapToUnspendTransaction(address: String, coinType: CoinType) -> BitcoinUnspentTransaction { - BitcoinUnspentTransaction.with { - $0.outPoint.hash = Data.reverse(hexString: transaction_id) - $0.outPoint.index = UInt32(vout) - $0.amount = Int64(value)! - $0.script = BitcoinScript.lockScriptForAddress(address: address, coin: coinType).data - } - } -} - -public extension [BitcoinUnspentTransaction] { - func mapToScripts(address: String, coinType: CoinType) -> [String: Data] { - reduce(into: [String: Data]()) { map, _ in - let script = BitcoinScript.lockScriptForAddress(address: address, coin: coinType) - - guard coinType != .bitcoin, !script.data.isEmpty else { - return - } - if let scriptHash = script.matchPayToScriptHash() { - map[scriptHash.hexString] = script.matchPayToWitnessPublicKeyHash() - } else if let scriptHash = script.matchPayToWitnessPublicKeyHash() { - map[scriptHash.hexString] = script.matchPayToPubkeyHash() - } - } - } -} diff --git a/ios/Packages/WalletCore/Tests/WalletCorePrimitivesTests/Chain+WalletCorePrimitiveTests.swift b/ios/Packages/WalletCore/Tests/WalletCorePrimitivesTests/Chain+WalletCorePrimitiveTests.swift index 09eb418f2..a34e8ef68 100644 --- a/ios/Packages/WalletCore/Tests/WalletCorePrimitivesTests/Chain+WalletCorePrimitiveTests.swift +++ b/ios/Packages/WalletCore/Tests/WalletCorePrimitivesTests/Chain+WalletCorePrimitiveTests.swift @@ -68,17 +68,6 @@ final class Chain_WalletCorePrimitiveTests { #expect(chain.coinType == expected) } - @Test - func testIsValidAddress() { - // Expect addresses to be valid - #expect(Chain.mock(.ethereum).isValidAddress("0x95222290DD7278Aa3Ddd389Cc1E1d165CC4BAfe5")) - #expect(Chain.mock(.ethereum).isValidAddress("0x95222290DD7278Aa3Ddd389Cc1E1d165CC4BAfe5")) - - // Expect addresses to be invalid - #expect(!Chain.mock(.ethereum).isValidAddress("0x123")) - #expect(!Chain.mock(.ethereum).isValidAddress("0x123")) - } - @Test func testChecksumAddress() { let bitocoinAddress = "bc1qr6f065nr70x4gl6ja9lm5wfj7xkhdv2sq04q23"