Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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)));

Expand Down
2 changes: 1 addition & 1 deletion src/shared/Angor.Shared.Tests/InvestmentOperationsTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<INetworkConfiguration>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<INetworkConfiguration>();
Expand Down
11 changes: 5 additions & 6 deletions src/shared/Angor.Shared.Tests/WalletOperationsTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
8 changes: 7 additions & 1 deletion src/shared/Angor.Shared/IWalletOperations.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@

namespace Angor.Shared;

/// <summary>
/// A coin paired with its signing key, ensuring they cannot be mismatched.
/// Replaces the unsafe parallel List&lt;Coin&gt;/List&lt;Key&gt; pattern.
/// </summary>
public record SigningCoin(Coin Coin, Key Key);

public interface IWalletOperations
{
string GenerateWalletWords();
Expand All @@ -14,7 +20,7 @@ public interface IWalletOperations
List<UtxoDataWithPath> FindOutputsForTransaction(long sendAmountat, AccountInfo accountInfo);
Task<IEnumerable<FeeEstimation>> GetFeeEstimationAsync();
Transaction CreateSendTransaction(SendInfo sendInfo, AccountInfo accountInfo);
(List<Coin>? coins, List<Key> keys) GetUnspentOutputsForTransaction(WalletWords walletWords, List<UtxoDataWithPath> utxoDataWithPaths);
List<SigningCoin> GetUnspentOutputsForTransaction(WalletWords walletWords, List<UtxoDataWithPath> utxoDataWithPaths);

TransactionInfo AddInputsAndSignTransaction(string changeAddress, Transaction transaction,
WalletWords walletWords, AccountInfo accountInfo, long feeRate);
Expand Down
126 changes: 56 additions & 70 deletions src/shared/Angor.Shared/WalletOperations.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,25 +40,25 @@
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)
{
// Step 1: Clone transaction for modification
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);
}
Expand All @@ -77,30 +77,25 @@
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 };
}
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)));

Expand All @@ -121,9 +116,9 @@

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;
Expand Down Expand Up @@ -175,20 +170,20 @@
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);
}
Expand Down Expand Up @@ -216,20 +211,16 @@
}

// 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 };
Expand All @@ -251,14 +242,14 @@

// 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);
}
Expand All @@ -269,7 +260,7 @@
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
Expand All @@ -285,21 +276,16 @@
}

// 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 };
Expand All @@ -316,18 +302,18 @@
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<Transaction> { 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)));

Expand Down Expand Up @@ -371,7 +357,7 @@
{
list.Add(new UtxoData
{
address = output.TxOut.ScriptPubKey.GetDestinationAddress(network.BitcoinNetwork).ToString(),

Check warning on line 360 in src/shared/Angor.Shared/WalletOperations.cs

View workflow job for this annotation

GitHub Actions / validate-desktop-build

Dereference of a possibly null reference.
scriptHex = output.TxOut.ScriptPubKey.ToHex(),
outpoint = new Outpoint(transactionHash, (int)output.N),
blockIndex = 0,
Expand Down Expand Up @@ -416,7 +402,7 @@
return utxosToSpend;
}

public (List<Coin>? coins,List<Key> keys) GetUnspentOutputsForTransaction(WalletWords walletWords , List<UtxoDataWithPath> utxoDataWithPaths)
public List<SigningCoin> GetUnspentOutputsForTransaction(WalletWords walletWords , List<UtxoDataWithPath> utxoDataWithPaths)
{
ExtKey extendedKey;
try
Expand All @@ -433,23 +419,23 @@
throw;
}

var coins = new List<Coin>();
var keys = new List<Key>();
var signingCoins = new List<SigningCoin>();

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;
}


Expand Down Expand Up @@ -727,7 +713,7 @@
if (feeEstimations == null || (!feeEstimations.Fees?.Any() ?? true))
return blocks.Select(_ => new FeeEstimation{Confirmations = _,FeeRate = 10000 / _}); // default to 1 satoshi per byte for 10 blocks and 10 satoshi for 1 block

_logger.LogInformation($"fee estimation is {string.Join(", ", feeEstimations.Fees.Select(f => f.Confirmations.ToString() + "-" + f.FeeRate))}");

Check warning on line 716 in src/shared/Angor.Shared/WalletOperations.cs

View workflow job for this annotation

GitHub Actions / validate-desktop-build

Possible null reference argument for parameter 'source' in 'IEnumerable<string> Enumerable.Select<FeeEstimation, string>(IEnumerable<FeeEstimation> source, Func<FeeEstimation, string> selector)'.

return feeEstimations.Fees!;
}
Expand Down
Loading