diff --git a/src/shared/Angor.Shared.Tests/DataBuilders/InvestmentOperations.cs b/src/shared/Angor.Shared.Tests/DataBuilders/InvestmentOperations.cs index d796d0e6e..0d99d6fae 100644 --- a/src/shared/Angor.Shared.Tests/DataBuilders/InvestmentOperations.cs +++ b/src/shared/Angor.Shared.Tests/DataBuilders/InvestmentOperations.cs @@ -74,18 +74,18 @@ public Transaction SignInvestmentTransaction(AngorNetwork network,string changeA //var nbitcoinNetwork = network; //var trx = NBitcoin.Transaction.Parse(transaction.ToHex(), nbitcoinNetwork); - var coins = _walletOperations.GetUnspentOutputsForTransaction(walletWords, utxoDataWithPaths); + var signingCoins = _walletOperations.GetUnspentOutputsForTransaction(walletWords, utxoDataWithPaths); // var fees = _walletOperations.GetFeeEstimationAsync().GetAwaiter().GetResult(); // var fee = fees.First(f => f.Confirmations == 1); - //var incoins = coins.coins.Select(c => new NBitcoin.Coin(OutPoint.Parse(c.Outpoint.ToString()), new NBitcoin.TxOut(NBitcoin.Money.Satoshis(c.Amount.Satoshi), new NBitcoin.Script(c.ScriptPubKey.ToBytes())))); - //var inKeys = coins.keys.Select(k => new Key(k.ToBytes())).ToArray(); + //var incoins = signingCoins.Select(sc => new NBitcoin.Coin(OutPoint.Parse(sc.Coin.Outpoint.ToString()), new NBitcoin.TxOut(NBitcoin.Money.Satoshis(sc.Coin.Amount.Satoshi), new NBitcoin.Script(sc.Coin.ScriptPubKey.ToBytes())))); + //var inKeys = signingCoins.Select(sc => new Key(sc.Key.ToBytes())).ToArray(); var builder = network.BitcoinNetwork.CreateTransactionBuilder() - .AddCoins(coins.coins) - .AddKeys(coins.keys.ToArray()) + .AddCoins(signingCoins.Select(sc => sc.Coin)) + .AddKeys(signingCoins.Select(sc => sc.Key).ToArray()) .SetChange(BitcoinAddress.Create(changeAddress, network.BitcoinNetwork)) .SendEstimatedFees(new FeeRate(Money.Satoshis(feeRate.FeeRate))); diff --git a/src/shared/Angor.Shared.Tests/InvestmentOperationsTest.cs b/src/shared/Angor.Shared.Tests/InvestmentOperationsTest.cs index feb924b42..0cf25f579 100644 --- a/src/shared/Angor.Shared.Tests/InvestmentOperationsTest.cs +++ b/src/shared/Angor.Shared.Tests/InvestmentOperationsTest.cs @@ -45,7 +45,7 @@ public InvestmentOperationsTest() var coins = keys.Select(key => new Coin(fakeInputTrx, fakeTxout)).ToList(); - return (coins, keys); + return coins.Zip(keys, (c, k) => new SigningCoin(c, k)).ToList(); }); _networkConfiguration = new Mock(); diff --git a/src/shared/Angor.Shared.Tests/Protocol/FounderTransactionActionTest.cs b/src/shared/Angor.Shared.Tests/Protocol/FounderTransactionActionTest.cs index c8e4fa643..d993b8c6c 100644 --- a/src/shared/Angor.Shared.Tests/Protocol/FounderTransactionActionTest.cs +++ b/src/shared/Angor.Shared.Tests/Protocol/FounderTransactionActionTest.cs @@ -38,7 +38,7 @@ public FounderTransactionActionTest() var coins = keys.Select(key => fakeInputTrx.Outputs.AsCoins().First()).ToList(); - return (coins, keys); + return coins.Zip(keys, (c, k) => new SigningCoin(c, k)).ToList(); }); diff --git a/src/shared/Angor.Shared.Tests/Protocol/InvestmentIntegrationsTests.cs b/src/shared/Angor.Shared.Tests/Protocol/InvestmentIntegrationsTests.cs index ed809f8a5..8d3d7b8f2 100644 --- a/src/shared/Angor.Shared.Tests/Protocol/InvestmentIntegrationsTests.cs +++ b/src/shared/Angor.Shared.Tests/Protocol/InvestmentIntegrationsTests.cs @@ -51,7 +51,7 @@ public InvestmentIntegrationsTests() var coins = keys.Select(key => new Coin(fakeInputTrx, fakeTxout)).ToList(); - return (coins, keys); + return coins.Zip(keys, (c, k) => new SigningCoin(c, k)).ToList(); }); _networkConfiguration = new Mock(); diff --git a/src/shared/Angor.Shared.Tests/WalletOperationsTest.cs b/src/shared/Angor.Shared.Tests/WalletOperationsTest.cs index b9cb20b9f..55cc81169 100644 --- a/src/shared/Angor.Shared.Tests/WalletOperationsTest.cs +++ b/src/shared/Angor.Shared.Tests/WalletOperationsTest.cs @@ -384,13 +384,12 @@ public void GetUnspentOutputsForTransaction_ReturnsCorrectOutputs() var walletOperations = new WalletOperations(null, mockHdOperations.Object, null, null); // Act - var (coins, keys) = walletOperations.GetUnspentOutputsForTransaction(walletWords, utxos); + var signingCoins = walletOperations.GetUnspentOutputsForTransaction(walletWords, utxos); // Assert - Assert.Single(coins); - Assert.Single(keys); - Assert.Equal((uint)0, coins[0].Outpoint.N); - Assert.Equal(1500000, coins[0].Amount.Satoshi); - Assert.Equal(expectedExtKey.Derive(new KeyPath("m/0/0")).PrivateKey, keys[0]); + Assert.Single(signingCoins); + Assert.Equal((uint)0, signingCoins[0].Coin.Outpoint.N); + Assert.Equal(1500000, signingCoins[0].Coin.Amount.Satoshi); + Assert.Equal(expectedExtKey.Derive(new KeyPath("m/0/0")).PrivateKey, signingCoins[0].Key); } } diff --git a/src/shared/Angor.Shared/IWalletOperations.cs b/src/shared/Angor.Shared/IWalletOperations.cs index 155e2ee36..083fa7939 100644 --- a/src/shared/Angor.Shared/IWalletOperations.cs +++ b/src/shared/Angor.Shared/IWalletOperations.cs @@ -3,6 +3,12 @@ namespace Angor.Shared; +/// +/// A coin paired with its signing key, ensuring they cannot be mismatched. +/// Replaces the unsafe parallel List<Coin>/List<Key> pattern. +/// +public record SigningCoin(Coin Coin, Key Key); + public interface IWalletOperations { string GenerateWalletWords(); @@ -14,7 +20,7 @@ public interface IWalletOperations List FindOutputsForTransaction(long sendAmountat, AccountInfo accountInfo); Task> GetFeeEstimationAsync(); Transaction CreateSendTransaction(SendInfo sendInfo, AccountInfo accountInfo); - (List? coins, List keys) GetUnspentOutputsForTransaction(WalletWords walletWords, List utxoDataWithPaths); + List GetUnspentOutputsForTransaction(WalletWords walletWords, List utxoDataWithPaths); TransactionInfo AddInputsAndSignTransaction(string changeAddress, Transaction transaction, WalletWords walletWords, AccountInfo accountInfo, long feeRate); diff --git a/src/shared/Angor.Shared/WalletOperations.cs b/src/shared/Angor.Shared/WalletOperations.cs index 482d351cf..0278e3de5 100644 --- a/src/shared/Angor.Shared/WalletOperations.cs +++ b/src/shared/Angor.Shared/WalletOperations.cs @@ -40,13 +40,13 @@ public TransactionInfo AddInputsAndSignTransaction(string changeAddress, Transac AngorNetwork network = _networkConfiguration.GetNetwork(); var utxoDataWithPaths = FindOutputsForTransaction(transaction.Outputs.Sum(_ => _.Value.Satoshi), accountInfo); - var coins = GetUnspentOutputsForTransaction(walletWords, utxoDataWithPaths); + var signingCoins = GetUnspentOutputsForTransaction(walletWords, utxoDataWithPaths); - if (coins.coins == null) + if (signingCoins == null || !signingCoins.Any()) throw new ApplicationException("No coins found"); // did we spend all the coins? - var spendAll = coins.coins.Sum(s => s.Amount.Satoshi) == transaction.Outputs.Sum(o => o.Value.Satoshi); + var spendAll = signingCoins.Sum(s => s.Coin.Amount.Satoshi) == transaction.Outputs.Sum(o => o.Value.Satoshi); if (spendAll) { @@ -54,11 +54,11 @@ public TransactionInfo AddInputsAndSignTransaction(string changeAddress, Transac var clonedTransaction = network.CreateTransaction(transaction.ToHex()); // Step 2: Add inputs and recalculate the transaction size - foreach (var coin in coins.coins) + foreach (var sc in signingCoins) { - if (clonedTransaction.Inputs.Any(x => x.PrevOut == coin.Outpoint)) + if (clonedTransaction.Inputs.Any(x => x.PrevOut == sc.Coin.Outpoint)) continue; - var txin = new TxIn(coin.Outpoint); + var txin = new TxIn(sc.Coin.Outpoint); txin.WitScript = new WitScript(Op.GetPushOp(new byte[72]), Op.GetPushOp(new byte[33])); // for total size calculation clonedTransaction.Inputs.Add(txin); } @@ -77,21 +77,16 @@ public TransactionInfo AddInputsAndSignTransaction(string changeAddress, Transac lastOutput.Value -= Money.Satoshis(totalFee); // Step 6: Sign inputs - var index = 0; - foreach (var coin in coins.coins) + foreach (var sc in signingCoins) { - var key = coins.keys[index]; - - if (key.PubKey.WitHash.ScriptPubKey != coin.ScriptPubKey) - throw new InvalidOperationException($"Derived key does not match coin ScriptPubKey at index {index}"); - - var inputIndex = FindInputIndex(clonedTransaction, coin.Outpoint); - var scriptCode = key.PubKey.Hash.ScriptPubKey; - var sighash = clonedTransaction.GetSignatureHash(scriptCode, inputIndex, SigHash.All, coin.TxOut, HashVersion.WitnessV0); - var signature = new TransactionSignature(key.Sign(sighash), SigHash.All); - clonedTransaction.Inputs[inputIndex].WitScript = new WitScript(Op.GetPushOp(signature.ToBytes()), Op.GetPushOp(key.PubKey.ToBytes())); - - index++; + if (sc.Key.PubKey.WitHash.ScriptPubKey != sc.Coin.ScriptPubKey) + throw new InvalidOperationException($"Derived key does not match coin ScriptPubKey for outpoint {sc.Coin.Outpoint}"); + + var inputIndex = FindInputIndex(clonedTransaction, sc.Coin.Outpoint); + var scriptCode = sc.Key.PubKey.Hash.ScriptPubKey; + var sighash = clonedTransaction.GetSignatureHash(scriptCode, inputIndex, SigHash.All, sc.Coin.TxOut, HashVersion.WitnessV0); + var signature = new TransactionSignature(sc.Key.Sign(sighash), SigHash.All); + clonedTransaction.Inputs[inputIndex].WitScript = new WitScript(Op.GetPushOp(signature.ToBytes()), Op.GetPushOp(sc.Key.PubKey.ToBytes())); } return new TransactionInfo { Transaction = clonedTransaction, TransactionFee = totalFee }; @@ -99,8 +94,8 @@ public TransactionInfo AddInputsAndSignTransaction(string changeAddress, Transac else { var builder = network.BitcoinNetwork.CreateTransactionBuilder() - .AddCoins(coins.coins) - .AddKeys(coins.keys.ToArray()) + .AddCoins(signingCoins.Select(sc => sc.Coin)) + .AddKeys(signingCoins.Select(sc => sc.Key).ToArray()) .SetChange(BitcoinAddress.Create(changeAddress, network.BitcoinNetwork)) .SendEstimatedFees(new FeeRate(Money.Satoshis(feeRate))); @@ -121,9 +116,9 @@ public TransactionInfo AddInputsAndSignTransaction(string changeAddress, Transac foreach (var input in signTransaction.Inputs) { - var foundInput = coins.coins.First(c => c.Outpoint.ToString() == input.PrevOut.ToString()); + var foundInput = signingCoins.First(sc => sc.Coin.Outpoint.ToString() == input.PrevOut.ToString()); - totaInInputs += foundInput.Amount.Satoshi; + totaInInputs += foundInput.Coin.Amount.Satoshi; } var minerFee = totaInInputs - totaInOutputs; @@ -175,20 +170,20 @@ public TransactionInfo AddInputsFromAddressAndSignTransaction(string fundingAddr throw new ApplicationException( $"Insufficient funds in address {fundingAddress}. Required: {requiredAmount} sats ({Money.Satoshis(requiredAmount).ToUnit(MoneyUnit.BTC):F8} BTC), Available: {totalAvailable} sats ({Money.Satoshis(totalAvailable).ToUnit(MoneyUnit.BTC):F8} BTC)"); - var coins = GetUnspentOutputsForTransaction(walletWords, availableUtxos); + var signingCoins = GetUnspentOutputsForTransaction(walletWords, availableUtxos); - if (coins.coins == null || !coins.coins.Any()) + if (signingCoins == null || !signingCoins.Any()) throw new ApplicationException("Failed to get coins for the funding address"); // Clone transaction var clonedTransaction = network.CreateTransaction(transaction.ToHex()); // Add inputs - foreach (var coin in coins.coins) + foreach (var sc in signingCoins) { - if (clonedTransaction.Inputs.Any(x => x.PrevOut == coin.Outpoint)) + if (clonedTransaction.Inputs.Any(x => x.PrevOut == sc.Coin.Outpoint)) continue; - var txin = new TxIn(coin.Outpoint); + var txin = new TxIn(sc.Coin.Outpoint); txin.WitScript = new WitScript(Op.GetPushOp(new byte[72]), Op.GetPushOp(new byte[33])); clonedTransaction.Inputs.Add(txin); } @@ -216,20 +211,16 @@ public TransactionInfo AddInputsFromAddressAndSignTransaction(string fundingAddr } // Sign inputs - var index = 0; - foreach (var coin in coins.coins) + foreach (var sc in signingCoins) { - var key = coins.keys[index]; - - if (key.PubKey.WitHash.ScriptPubKey != coin.ScriptPubKey) - throw new InvalidOperationException($"Derived key does not match coin ScriptPubKey at index {index}"); + if (sc.Key.PubKey.WitHash.ScriptPubKey != sc.Coin.ScriptPubKey) + throw new InvalidOperationException($"Derived key does not match coin ScriptPubKey for outpoint {sc.Coin.Outpoint}"); - var inputIndex = FindInputIndex(clonedTransaction, coin.Outpoint); - var scriptCode = key.PubKey.Hash.ScriptPubKey; - var sighash = clonedTransaction.GetSignatureHash(scriptCode, inputIndex, SigHash.All, coin.TxOut, HashVersion.WitnessV0); - var signature = new TransactionSignature(key.Sign(sighash), SigHash.All); - clonedTransaction.Inputs[inputIndex].WitScript = new WitScript(Op.GetPushOp(signature.ToBytes()), Op.GetPushOp(key.PubKey.ToBytes())); - index++; + var inputIndex = FindInputIndex(clonedTransaction, sc.Coin.Outpoint); + var scriptCode = sc.Key.PubKey.Hash.ScriptPubKey; + var sighash = clonedTransaction.GetSignatureHash(scriptCode, inputIndex, SigHash.All, sc.Coin.TxOut, HashVersion.WitnessV0); + var signature = new TransactionSignature(sc.Key.Sign(sighash), SigHash.All); + clonedTransaction.Inputs[inputIndex].WitScript = new WitScript(Op.GetPushOp(signature.ToBytes()), Op.GetPushOp(sc.Key.PubKey.ToBytes())); } return new TransactionInfo { Transaction = clonedTransaction, TransactionFee = totalFee }; @@ -251,14 +242,14 @@ public TransactionInfo AddFeeAndSignTransaction(string changeAddress, Transactio // Step 2: Find UTXOs to fund the total cost of transaction (outputs + initial fee) var utxoDataWithPaths = FindOutputsForTransaction(initialFee, accountInfo); - var coins = GetUnspentOutputsForTransaction(walletWords, utxoDataWithPaths); + var signingCoins = GetUnspentOutputsForTransaction(walletWords, utxoDataWithPaths); // Step 3: Add inputs and recalculate the transaction size - foreach (var coin in coins.coins) + foreach (var sc in signingCoins) { - if (clonedTransaction.Inputs.Any(x => x.PrevOut == coin.Outpoint)) + if (clonedTransaction.Inputs.Any(x => x.PrevOut == sc.Coin.Outpoint)) continue; - var txin = new TxIn(coin.Outpoint); + var txin = new TxIn(sc.Coin.Outpoint); txin.WitScript = new WitScript(Op.GetPushOp(new byte[72]), Op.GetPushOp(new byte[33])); // for total size calculation clonedTransaction.Inputs.Add(txin); } @@ -269,7 +260,7 @@ public TransactionInfo AddFeeAndSignTransaction(string changeAddress, Transactio long totalFee = new FeeRate(Money.Satoshis(feeRate)).GetFee(totalSize).Satoshi; // Step 5: Adjust the change output (remaining coins after paying the fee) - var totalSats = coins.coins.Sum(s => s.Amount.Satoshi); + var totalSats = signingCoins.Sum(s => s.Coin.Amount.Satoshi); totalSats -= totalFee; // Handle cases where change is below "dust threshold" for SegWit @@ -285,21 +276,16 @@ public TransactionInfo AddFeeAndSignTransaction(string changeAddress, Transactio } // Step 6: Sign inputs - var index = 0; - foreach (var coin in coins.coins) + foreach (var sc in signingCoins) { - var key = coins.keys[index]; + if (sc.Key.PubKey.WitHash.ScriptPubKey != sc.Coin.ScriptPubKey) + throw new InvalidOperationException($"Derived key does not match coin ScriptPubKey for outpoint {sc.Coin.Outpoint}"); - if (key.PubKey.WitHash.ScriptPubKey != coin.ScriptPubKey) - throw new InvalidOperationException($"Derived key does not match coin ScriptPubKey at index {index}"); - - var inputIndex = FindInputIndex(clonedTransaction, coin.Outpoint); - var scriptCode = key.PubKey.Hash.ScriptPubKey; - var sighash = clonedTransaction.GetSignatureHash(scriptCode, inputIndex, SigHash.All, coin.TxOut, HashVersion.WitnessV0); - var signature = new TransactionSignature(key.Sign(sighash), SigHash.All); - clonedTransaction.Inputs[inputIndex].WitScript = new WitScript(Op.GetPushOp(signature.ToBytes()), Op.GetPushOp(key.PubKey.ToBytes())); - - index++; + var inputIndex = FindInputIndex(clonedTransaction, sc.Coin.Outpoint); + var scriptCode = sc.Key.PubKey.Hash.ScriptPubKey; + var sighash = clonedTransaction.GetSignatureHash(scriptCode, inputIndex, SigHash.All, sc.Coin.TxOut, HashVersion.WitnessV0); + var signature = new TransactionSignature(sc.Key.Sign(sighash), SigHash.All); + clonedTransaction.Inputs[inputIndex].WitScript = new WitScript(Op.GetPushOp(signature.ToBytes()), Op.GetPushOp(sc.Key.PubKey.ToBytes())); } return new TransactionInfo { Transaction = clonedTransaction, TransactionFee = totalFee }; @@ -316,18 +302,18 @@ public async Task> throw new ApplicationException("not enough funds"); } - var (coins, keys) = + var signingCoins = GetUnspentOutputsForTransaction(walletWords, sendInfo.SendUtxos.Values.ToList()); - if (coins == null) + if (signingCoins == null || !signingCoins.Any()) { return new OperationResult { Success = false, Message = "not enough funds" }; } var builder = network.BitcoinNetwork.CreateTransactionBuilder() .Send(BitcoinAddress.Create(sendInfo.SendToAddress, network.BitcoinNetwork), Money.Satoshis(sendInfo.SendAmount)) - .AddCoins(coins) - .AddKeys(keys.ToArray()) + .AddCoins(signingCoins.Select(sc => sc.Coin)) + .AddKeys(signingCoins.Select(sc => sc.Key).ToArray()) .SetChange(BitcoinAddress.Create(sendInfo.ChangeAddress, network.BitcoinNetwork)) .SendEstimatedFees(new FeeRate(Money.Satoshis(sendInfo.FeeRate))); @@ -416,7 +402,7 @@ public List return utxosToSpend; } - public (List? coins,List keys) GetUnspentOutputsForTransaction(WalletWords walletWords , List utxoDataWithPaths) + public List GetUnspentOutputsForTransaction(WalletWords walletWords , List utxoDataWithPaths) { ExtKey extendedKey; try @@ -433,23 +419,23 @@ public List throw; } - var coins = new List(); - var keys = new List(); + var signingCoins = new List(); foreach (var utxoDataWithPath in utxoDataWithPaths) { var utxo = utxoDataWithPath.UtxoData; - coins.Add(new Coin(uint256.Parse(utxo.outpoint.transactionId), (uint)utxo.outpoint.outputIndex, - Money.Satoshis(utxo.value), Script.FromHex(utxo.scriptHex))); + var coin = new Coin(uint256.Parse(utxo.outpoint.transactionId), (uint)utxo.outpoint.outputIndex, + Money.Satoshis(utxo.value), Script.FromHex(utxo.scriptHex)); // derive the private key var extKey = extendedKey.Derive(new KeyPath(utxoDataWithPath.HdPath)); Key privateKey = extKey.PrivateKey; - keys.Add(privateKey); + + signingCoins.Add(new SigningCoin(coin, privateKey)); } - return (coins,keys); + return signingCoins; }