private static Dictionary <string, int> GetAddressAccountLookup(Organization organization) { Dictionary <string, int> result = new Dictionary <string, int>(); // Add all cold addresses GetAddressAccountLookupRecurse(organization.FinancialAccounts.AssetsBitcoinCold, result); // Add all hot addresses HotBitcoinAddresses hotAddresses = HotBitcoinAddresses.ForOrganization(organization); foreach (HotBitcoinAddress hotAddress in hotAddresses) { result[hotAddress.Address] = organization.FinancialAccounts.AssetsBitcoinHot.Identity; } return(result); }
public static BitcoinTransactionInputs GetInputsForAmount(Organization organization, BitcoinChain chain, Int64 satoshis) { // TODO: Verify inputs up to date? // TODO: What's the most efficient way to do this? HotBitcoinAddresses addresses = HotBitcoinAddresses.ForOrganization(organization, BitcoinChain.Cash); HotBitcoinAddressUnspents unspents = addresses.Unspents; // Lazy checking: if there's one single input that covers the entire amount, use it BitcoinTransactionInput lowestSingleSufficientInput = null; foreach (HotBitcoinAddressUnspent unspent in unspents) { if (unspent.Address.Chain == BitcoinChain.Cash) { if (unspent.AmountSatoshis >= satoshis) { if (lowestSingleSufficientInput == null || lowestSingleSufficientInput.AmountSatoshis > unspent.AmountSatoshis) { lowestSingleSufficientInput = unspent.AsInput; } } } } if (lowestSingleSufficientInput != null) { // There is a single input that will cover the entire amount, so return it return(BitcoinTransactionInputs.FromSingle(lowestSingleSufficientInput)); } throw new NotEnoughFundsException("Insufficient funds", "group argument", new Satoshis(satoshis)); // Slightly more complex checking (TODO) }
public static void PerformAutomated(BitcoinChain chain) { // Perform all waiting hot payouts for all orgs in the installation throw new NotImplementedException("Waiting for rewrite for Bitcoin Cash"); // TODO DateTime utcNow = DateTime.UtcNow; foreach (Organization organization in Organizations.GetAll()) { // If this org doesn't do hotwallet, continue if (organization.FinancialAccounts.AssetsBitcoinHot == null) { continue; } Payouts orgPayouts = Payouts.Construct(organization); Payouts bitcoinPayouts = new Payouts(); Dictionary <string, Int64> satoshiPayoutLookup = new Dictionary <string, long>(); Dictionary <string, Int64> nativeCentsPayoutLookup = new Dictionary <string, long>(); Dictionary <int, Int64> satoshiPersonLookup = new Dictionary <int, long>(); Dictionary <int, Int64> nativeCentsPersonLookup = new Dictionary <int, long>(); Int64 satoshisTotal = 0; string currencyCode = organization.Currency.Code; // For each ready payout that can automate, add an output to a constructed transaction TransactionBuilder txBuilder = null; // TODO TODO TODO TODO new TransactionBuilder(); foreach (Payout payout in orgPayouts) { if (payout.ExpectedTransactionDate > utcNow) { continue; // payout is not due yet } if (payout.RecipientPerson != null && payout.RecipientPerson.BitcoinPayoutAddress.Length > 2 && payout.Account.Length < 4) { // If the payout address is still in quarantine, don't pay out yet string addressSetTime = payout.RecipientPerson.BitcoinPayoutAddressTimeSet; if (addressSetTime.Length > 4 && DateTime.Parse(addressSetTime, CultureInfo.InvariantCulture).AddHours(48) > utcNow) { continue; // still in quarantine } // Test the payout address - is it valid and can we handle it? if (!BitcoinUtility.IsValidBitcoinAddress(payout.RecipientPerson.BitcoinPayoutAddress)) { // Notify person that address is invalid, then clear it NotificationStrings primaryStrings = new NotificationStrings(); NotificationCustomStrings secondaryStrings = new NotificationCustomStrings(); primaryStrings[NotificationString.OrganizationName] = organization.Name; secondaryStrings["BitcoinAddress"] = payout.RecipientPerson.BitcoinPayoutAddress; OutboundComm.CreateNotification(organization, NotificationResource.BitcoinPayoutAddress_Bad, primaryStrings, secondaryStrings, People.FromSingle(payout.RecipientPerson)); payout.RecipientPerson.BitcoinPayoutAddress = string.Empty; continue; // do not add this payout } // Ok, so it seems we're making this payout at this time. bitcoinPayouts.Add(payout); int recipientPersonId = payout.RecipientPerson.Identity; if (!satoshiPersonLookup.ContainsKey(recipientPersonId)) { satoshiPersonLookup[recipientPersonId] = 0; nativeCentsPersonLookup[recipientPersonId] = 0; } nativeCentsPersonLookup[recipientPersonId] += payout.AmountCents; // Find the amount of satoshis for this payout if (organization.Currency.IsBitcoinCore) { satoshiPayoutLookup[payout.ProtoIdentity] = payout.AmountCents; nativeCentsPayoutLookup[payout.ProtoIdentity] = payout.AmountCents; satoshisTotal += payout.AmountCents; satoshiPersonLookup[recipientPersonId] += payout.AmountCents; } else { // Convert currency Money payoutAmount = new Money(payout.AmountCents, organization.Currency); Int64 satoshis = payoutAmount.ToCurrency(Currency.BitcoinCore).Cents; satoshiPayoutLookup[payout.ProtoIdentity] = satoshis; nativeCentsPayoutLookup[payout.ProtoIdentity] = payout.AmountCents; satoshisTotal += satoshis; satoshiPersonLookup[recipientPersonId] += satoshis; } } else if (payout.RecipientPerson != null && payout.RecipientPerson.BitcoinPayoutAddress.Length < 3 && payout.Account.Length < 4) { // There is a payout for this person, but they don't have a bitcoin payout address set. Send notification to this effect once a day. if (utcNow.Minute != 0) { continue; } if (utcNow.Hour != 12) { continue; } NotificationStrings primaryStrings = new NotificationStrings(); primaryStrings[NotificationString.OrganizationName] = organization.Name; OutboundComm.CreateNotification(organization, NotificationResource.BitcoinPayoutAddress_PleaseSet, primaryStrings, People.FromSingle(payout.RecipientPerson)); } else if (payout.Account.StartsWith("bitcoin:")) { } } if (bitcoinPayouts.Count == 0) { // no automated payments pending for this organization, nothing more to do continue; } // We now have our desired payments. The next step is to find enough inputs to reach the required amount (plus fees; we're working a little blind here still). BitcoinTransactionInputs inputs = null; Int64 satoshisMaximumAnticipatedFees = BitcoinUtility.GetRecommendedFeePerThousandBytesSatoshis(chain) * 20; // assume max 20k transaction size try { inputs = BitcoinUtility.GetInputsForAmount(organization, satoshisTotal + satoshisMaximumAnticipatedFees); } catch (NotEnoughFundsException) { // If we're at the whole hour, send a notification to people responsible for refilling the hotwallet if (utcNow.Minute != 0) { continue; // we're not at the whole hour, so continue with next org instead } // Send urgent notification to top up the damn wallet so we can make payments NotificationStrings primaryStrings = new NotificationStrings(); primaryStrings[NotificationString.CurrencyCode] = organization.Currency.Code; primaryStrings[NotificationString.OrganizationName] = organization.Name; NotificationCustomStrings secondaryStrings = new NotificationCustomStrings(); Int64 satoshisAvailable = HotBitcoinAddresses.ForOrganization(organization).BalanceSatoshisTotal; secondaryStrings["AmountMissingMicrocoinsFloat"] = ((satoshisTotal - satoshisAvailable + satoshisMaximumAnticipatedFees) / 100.0).ToString("N2"); if (organization.Currency.IsBitcoinCore) { secondaryStrings["AmountNeededFloat"] = ((satoshisTotal + satoshisMaximumAnticipatedFees) / 100.0).ToString("N2"); secondaryStrings["AmountWalletFloat"] = (satoshisAvailable / 100.0).ToString("N2"); } else { // convert to org native currency secondaryStrings["AmountNeededFloat"] = (new Money(satoshisTotal, Currency.BitcoinCore).ToCurrency(organization.Currency).Cents / 100.0).ToString("N2"); secondaryStrings["AmountWalletFloat"] = (new Money(satoshisAvailable, Currency.BitcoinCore).ToCurrency(organization.Currency).Cents / 100.0).ToString("N2"); } OutboundComm.CreateNotification(organization, NotificationResource.Bitcoin_Shortage_Critical, primaryStrings, secondaryStrings, People.FromSingle(Person.FromIdentity(1))); continue; // with next organization } // If we arrive at this point, the previous function didn't throw, and we have enough money. // Ensure the existence of a cost account for bitcoin miner fees. organization.EnsureMinerFeeAccountExists(); // Add the inputs to the transaction. txBuilder = txBuilder.AddCoins(inputs.Coins); txBuilder = txBuilder.AddKeys(inputs.PrivateKeys); Int64 satoshisInput = inputs.AmountSatoshisTotal; // Add outputs and prepare notifications Int64 satoshisUsed = 0; Dictionary <int, List <string> > notificationSpecLookup = new Dictionary <int, List <string> >(); Dictionary <int, List <Int64> > notificationAmountLookup = new Dictionary <int, List <Int64> >(); Payout masterPayoutPrototype = Payout.Empty; HotBitcoinAddress changeAddress = HotBitcoinAddress.OrganizationWalletZero(organization, BitcoinChain.Core); // TODO: CHAIN! foreach (Payout payout in bitcoinPayouts) { int recipientPersonId = payout.RecipientPerson.Identity; if (!notificationSpecLookup.ContainsKey(recipientPersonId)) { notificationSpecLookup[recipientPersonId] = new List <string>(); notificationAmountLookup[recipientPersonId] = new List <Int64>(); } notificationSpecLookup[recipientPersonId].Add(payout.Specification); notificationAmountLookup[recipientPersonId].Add(payout.AmountCents); if (payout.RecipientPerson.BitcoinPayoutAddress.StartsWith("1")) // regular address { txBuilder = txBuilder.Send(new BitcoinPubKeyAddress(payout.RecipientPerson.BitcoinPayoutAddress), new Satoshis(satoshiPayoutLookup[payout.ProtoIdentity])); } else if (payout.RecipientPerson.BitcoinPayoutAddress.StartsWith("3")) // multisig { txBuilder = txBuilder.Send(new BitcoinScriptAddress(payout.RecipientPerson.BitcoinPayoutAddress, Network.Main), new Satoshis(satoshiPayoutLookup[payout.ProtoIdentity])); } else { throw new InvalidOperationException("Unhandled bitcoin address type in Payouts.PerformAutomated(): " + payout.RecipientPerson.BitcoinPayoutAddress); } satoshisUsed += satoshiPayoutLookup[payout.ProtoIdentity]; payout.MigrateDependenciesTo(masterPayoutPrototype); } // Set change address to wallet slush txBuilder.SetChange(new BitcoinPubKeyAddress(changeAddress.Address)); // Add fee int transactionSizeBytes = txBuilder.EstimateSize(txBuilder.BuildTransaction(false)) + inputs.Count; // +inputs.Count for size variability Int64 feeSatoshis = (transactionSizeBytes / 1000 + 1) * BitcoinUtility.GetRecommendedFeePerThousandBytesSatoshis(chain); txBuilder = txBuilder.SendFees(new Satoshis(feeSatoshis)); satoshisUsed += feeSatoshis; // Sign transaction - ready to execute Transaction txReady = txBuilder.BuildTransaction(true); // Verify that transaction is ready if (!txBuilder.Verify(txReady)) { // Transaction was not signed with the correct keys. This is a serious condition. NotificationStrings primaryStrings = new NotificationStrings(); primaryStrings[NotificationString.OrganizationName] = organization.Name; OutboundComm.CreateNotification(organization, NotificationResource.Bitcoin_PrivateKeyError, primaryStrings); throw new InvalidOperationException("Transaction is not signed enough"); } // Broadcast transaction BitcoinUtility.BroadcastTransaction(txReady, BitcoinChain.Cash); // Note the transaction hash string transactionHash = txReady.GetHash().ToString(); // Delete all old inputs, adjust balance for addresses (re-register unused inputs) inputs.AsUnspents.DeleteAll(); // Log the new unspent created by change (if there is any) if (satoshisInput - satoshisUsed > 0) { SwarmDb.GetDatabaseForWriting() .CreateHotBitcoinAddressUnspentConditional(changeAddress.Identity, transactionHash, +/* the change address seems to always get index 0? is this a safe assumption? */ 0, satoshisInput - satoshisUsed, /* confirmation count*/ 0); } // Register new balance of change address, should have increased by (satoshisInput-satoshisUsed) // TODO // Send notifications foreach (int personId in notificationSpecLookup.Keys) { Person person = Person.FromIdentity(personId); string spec = string.Empty; for (int index = 0; index < notificationSpecLookup[personId].Count; index++) { spec += String.Format(" * {0,-40} {1,14:N2} {2,-4}\r\n", notificationSpecLookup[personId][index], notificationAmountLookup[personId][index] / 100.0, currencyCode); } spec = spec.TrimEnd(); NotificationStrings primaryStrings = new NotificationStrings(); NotificationCustomStrings secondaryStrings = new NotificationCustomStrings(); primaryStrings[NotificationString.OrganizationName] = organization.Name; primaryStrings[NotificationString.CurrencyCode] = organization.Currency.DisplayCode; primaryStrings[NotificationString.EmbeddedPreformattedText] = spec; secondaryStrings["AmountFloat"] = (nativeCentsPersonLookup[personId] / 100.0).ToString("N2"); secondaryStrings["BitcoinAmountFloat"] = (satoshiPersonLookup[personId] / 100.0).ToString("N2"); secondaryStrings["BitcoinAddress"] = person.BitcoinPayoutAddress; // warn: potential rare race condition here OutboundComm.CreateNotification(organization, NotificationResource.Bitcoin_PaidOut, primaryStrings, secondaryStrings, People.FromSingle(person)); } // Create the master payout from its prototype Payout masterPayout = Payout.CreateBitcoinPayoutFromPrototype(organization, masterPayoutPrototype, txReady.GetHash().ToString()); // Finally, create ledger entries and notify NotificationStrings masterPrimaryStrings = new NotificationStrings(); NotificationCustomStrings masterSecondaryStrings = new NotificationCustomStrings(); masterPrimaryStrings[NotificationString.OrganizationName] = organization.Name; masterPrimaryStrings[NotificationString.CurrencyCode] = organization.Currency.DisplayCode; masterSecondaryStrings["AmountFloat"] = (new Swarmops.Logic.Financial.Money(satoshisUsed, Currency.BitcoinCore).ToCurrency( organization.Currency).Cents / 100.0).ToString("N2", CultureInfo.InvariantCulture); masterSecondaryStrings["BitcoinAmountFloat"] = (satoshisUsed / 100.0).ToString("N2", CultureInfo.InvariantCulture); masterSecondaryStrings["PaymentCount"] = bitcoinPayouts.Count.ToString("N0", CultureInfo.InvariantCulture); OutboundComm.CreateNotification(organization, NotificationResource.Bitcoin_Hotwallet_Outflow, masterPrimaryStrings, masterSecondaryStrings); // TODO: special case for native-bitcoin organizations vs. fiat-currency organizations FinancialTransaction ledgerTransaction = FinancialTransaction.Create(organization, utcNow, "Bitcoin automated payout"); if (organization.Currency.IsBitcoinCore) { ledgerTransaction.AddRow(organization.FinancialAccounts.AssetsBitcoinHot, -(masterPayoutPrototype.AmountCents + feeSatoshis), null); ledgerTransaction.AddRow(organization.FinancialAccounts.CostsBitcoinFees, feeSatoshis, null); } else { // If the ledger isn't using bitcoin natively, we need to translate the miner fee paid to ledger cents before entering it into ledger Int64 feeCentsLedger = new Money(feeSatoshis, Currency.BitcoinCore).ToCurrency(organization.Currency).Cents; ledgerTransaction.AddRow(organization.FinancialAccounts.AssetsBitcoinHot, -(masterPayoutPrototype.AmountCents + feeCentsLedger), null).AmountForeignCents = new Money(satoshisUsed, Currency.BitcoinCore); ledgerTransaction.AddRow(organization.FinancialAccounts.CostsBitcoinFees, feeCentsLedger, null); } ledgerTransaction.BlockchainHash = transactionHash; masterPayout.BindToTransactionAndClose(ledgerTransaction, null); } }
// TODO: Condense TestUnspents into ONE call for MULTIPLE addresses (separated by | for Unspents according to API docs) // TODO: Enable backend to call a running bitcoin node for all this instead of callign third party services public static bool TestUnspents(BitcoinChain chain, string address) { // This function queries the Blockchain API for the unspent coin. bool result = false; HotBitcoinAddress hotAddress = null; JObject addressInfoResult; JObject unspentJsonResult; switch (chain) { case BitcoinChain.Core: addressInfoResult = JObject.Parse( new WebClient().DownloadString( "https://blockchain.info/address/" + address + "?format=json&api_key=" + SystemSettings.BlockchainSwarmopsApiKey)); if ((int)addressInfoResult["final_balance"] == 0) { return(false); // no funds on address at all at this time } try { unspentJsonResult = JObject.Parse( new WebClient().DownloadString("https://blockchain.info/unspent?active=" + address + "&api_key=" + SystemSettings.BlockchainSwarmopsApiKey)); } catch (WebException webException) { // A 500 on the above _may_ mean that there's no unspent outpoints. It can also mean a data // retrieval or network error, in which case the exception must absolutely not be interpreted // as valid data of zero unspent outpoints. try { if (webException.Response == null) { throw; // if there's no response at all, we can't do shit } string errorResponseContent = new StreamReader(webException.Response.GetResponseStream()).ReadToEnd(); if (errorResponseContent.Trim().StartsWith("No free outputs to spend")) { // all is okay network-wise, there just aren't any UTXOs so we're getting an error code for that return(false); // no further processing and there are no fresh transactions } throw; // otherwise throw upward } catch (WebException) { // Ok, we tried, but there's apparently a network error so we need to abort this whole thing throw; } } foreach (var unspentJson in unspentJsonResult["unspent_outputs"]) { BitcoinUnspentTransactionOutput txUnspent = new BitcoinUnspentTransactionOutput() { BitcoinAddress = address, ConfirmationCount = (UInt32)unspentJson["confirmations"], Satoshis = (UInt64)unspentJson["value"], TransactionHash = (string)unspentJson["tx_hash_big_endian"], TransactionOutputIndex = (UInt32)unspentJson["tx_output_n"] }; if (txUnspent.ConfirmationCount < 2) { // Fresh transactions, return true result = true; } // Add unspent to database if (hotAddress == null) { hotAddress = HotBitcoinAddress.FromAddress(chain, address); } SwarmDb.GetDatabaseForWriting() .CreateHotBitcoinAddressUnspentConditional(hotAddress.Identity, txUnspent.TransactionHash, (int)txUnspent.TransactionOutputIndex, (Int64)txUnspent.Satoshis, (int)txUnspent.ConfirmationCount); } // Update hotaddress totals HotBitcoinAddresses.UpdateAllUnspentTotals(); return(result); case BitcoinChain.Cash: // TODO: SELECTION OF BLOCK EXPLORER, ADDRESS STRING FORMAT TO GO WITH IT addressInfoResult = JObject.Parse( new WebClient().DownloadString( "https://bitcoincash.blockexplorer.com/api/addr/" + address)); JArray unspentArray; if ((int)addressInfoResult["balanceSat"] == 0 && (int)addressInfoResult["unconfirmedBalanceSat"] == 0) { return(false); // no funds on address at all at this time } try { unspentArray = JArray.Parse( new WebClient().DownloadString("https://bitcoincash.blockexplorer.com/api/addr/" + address + "/utxo")); } catch (WebException webException) { // A 500 on the above _may_ mean that there's no unspent outpoints. It can also mean a data // retrieval or network error, in which case the exception must absolutely not be interpreted // as valid data of zero unspent outpoints. try { if (webException.Response == null) { throw; // if there's no response at all, we can't do shit } string errorResponseContent = new StreamReader(webException.Response.GetResponseStream()).ReadToEnd(); if (errorResponseContent.Trim().StartsWith("No free outputs to spend")) { // all is okay network-wise, there just aren't any UTXOs so we're getting an error code for that return(false); // no further processing and there are no fresh transactions } throw; // otherwise throw upward } catch (WebException) { // Ok, we tried, but there's apparently a network error so we need to abort this whole thing throw; } } foreach (JObject unspentJson in unspentArray.Children()) { BitcoinUnspentTransactionOutput txUnspent = new BitcoinUnspentTransactionOutput() { BitcoinAddress = address, ConfirmationCount = (UInt32)unspentJson["confirmations"], Satoshis = (UInt64)unspentJson["satoshis"], TransactionHash = (string)unspentJson["txid"], TransactionOutputIndex = (UInt32)unspentJson["vout"] }; if (txUnspent.ConfirmationCount < 2) { // Fresh transactions, return true result = true; } // Add unspent to database if (hotAddress == null) { hotAddress = HotBitcoinAddress.GetAddressOrForkCore(chain, address); } SwarmDb.GetDatabaseForWriting() .CreateHotBitcoinAddressUnspentConditional(hotAddress.Identity, txUnspent.TransactionHash, (int)txUnspent.TransactionOutputIndex, (Int64)txUnspent.Satoshis, (int)txUnspent.ConfirmationCount); } // Update hotaddress totals HotBitcoinAddresses.UpdateAllUnspentTotals(); return(result); default: throw new NotImplementedException("Unimplemented bitcoin chain: " + chain); } }