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); } }
private static void CheckColdStorageRecurse(FinancialAccount parent, Dictionary <string, int> addressAccountLookup) { foreach (FinancialAccount child in parent.Children) { CheckColdStorageRecurse(child, addressAccountLookup); } // After recursing, get all transactions for this account and verify against our records // is the account name a valid bitcoin address on the main network? string address = parent.BitcoinAddress; if (string.IsNullOrEmpty(address)) { if (BitcoinUtility.IsValidBitcoinAddress(parent.Name.Trim())) { parent.BitcoinAddress = address = parent.Name.Trim(); } else { return; // not a bitcoin address but something else; do not process } } Organization organization = parent.Organization; bool organizationLedgerUsesBitcoin = organization.Currency.IsBitcoinCore; JObject addressData = JObject.Parse( new WebClient().DownloadString("https://blockchain.info/address/" + address + "?format=json&api_key=" + SystemSettings.BlockchainSwarmopsApiKey)); //int transactionCount = (int) (addressData["n_tx"]); foreach (JObject txJson in addressData["txs"]) { FinancialTransaction ourTx = null; Dictionary <Int64, Int64> satoshisLookup = new Dictionary <long, long>(); // map from ledgercents to satoshis BlockchainTransaction blockchainTx = BlockchainTransaction.FromBlockchainInfoJson(txJson); try { ourTx = FinancialTransaction.FromBlockchainHash(parent.Organization, blockchainTx.TransactionHash); // If the transaction was fetched fine, we have already seen this transaction, but need to re-check it } catch (ArgumentException) { // We didn't have this transaction, so we need to create it ourTx = FinancialTransaction.Create(parent.Organization, blockchainTx.TransactionDateTimeUtc, "Blockchain tx"); ourTx.BlockchainHash = blockchainTx.TransactionHash; // Did we lose or gain money? // Find all in- and outpoints, determine which are ours (hot and cold wallet) and which aren't } Dictionary <int, long> transactionReconstructedRows = new Dictionary <int, long>(); // Note the non-blockchain rows in this tx, keep them for reconstruction foreach (FinancialTransactionRow row in ourTx.Rows) { if (!addressAccountLookup.ContainsValue(row.FinancialAccountId)) // not optimal but n is small { // This is not a bitcoin address account, so note it for reconstruction if (!transactionReconstructedRows.ContainsKey(row.FinancialAccountId)) { transactionReconstructedRows[row.FinancialAccountId] = 0; // init } transactionReconstructedRows[row.FinancialAccountId] += row.AmountCents; } else { // this is a known blockchain row, note its ledgered value in satoshis if (!organizationLedgerUsesBitcoin) { Money nativeMoney = row.AmountForeignCents; if (nativeMoney != null && nativeMoney.Currency.IsBitcoinCore) // it damn well should be, but just checking { satoshisLookup[row.AmountCents] = row.AmountForeignCents.Cents; } } } } // Reconstruct the blockchain rows: input, output, fees, in that order // -- inputs foreach (BlockchainTransactionRow inputRow in blockchainTx.Inputs) { if (addressAccountLookup.ContainsKey(inputRow.Address)) { // this input is ours int financialAccountId = addressAccountLookup[inputRow.Address]; if (!transactionReconstructedRows.ContainsKey(financialAccountId)) { transactionReconstructedRows[financialAccountId] = 0; // initialize } if (organizationLedgerUsesBitcoin) { transactionReconstructedRows[financialAccountId] += -inputRow.ValueSatoshis; // note the negation! } else { Int64 ledgerCents = new Money(inputRow.ValueSatoshis, Currency.BitcoinCore, ourTx.DateTime).ToCurrency( organization.Currency).Cents; transactionReconstructedRows[financialAccountId] += -ledgerCents; // note the negation! satoshisLookup[ledgerCents] = inputRow.ValueSatoshis; } } } // -- outputs foreach (BlockchainTransactionRow outputRow in blockchainTx.Outputs) { if (addressAccountLookup.ContainsKey(outputRow.Address)) { // this output is ours int financialAccountId = addressAccountLookup[outputRow.Address]; if (!transactionReconstructedRows.ContainsKey(financialAccountId)) { transactionReconstructedRows[financialAccountId] = 0; // initialize } if (organizationLedgerUsesBitcoin) { transactionReconstructedRows[financialAccountId] += outputRow.ValueSatoshis; } else { Int64 ledgerCents = new Money(outputRow.ValueSatoshis, Currency.BitcoinCore, ourTx.DateTime).ToCurrency( organization.Currency).Cents; transactionReconstructedRows[financialAccountId] += ledgerCents; satoshisLookup[ledgerCents] = outputRow.ValueSatoshis; } } } // -- fees if (addressAccountLookup.ContainsKey(blockchainTx.Inputs[0].Address)) { // if the first input is ours, we're paying the fee (is there any case where this is not true?) if (organizationLedgerUsesBitcoin) { transactionReconstructedRows[organization.FinancialAccounts.CostsBitcoinFees.Identity] = blockchainTx.FeeSatoshis; } else { Int64 feeLedgerCents = new Money(blockchainTx.FeeSatoshis, Currency.BitcoinCore, blockchainTx.TransactionDateTimeUtc).ToCurrency(organization.Currency).Cents; transactionReconstructedRows[organization.FinancialAccounts.CostsBitcoinFees.Identity] = feeLedgerCents; } } // Rewrite the transaction (called always, but the function won't do anything if everything matches) ourTx.RecalculateTransaction(transactionReconstructedRows, /* loggingPerson*/ null); // Finally, add foreign cents, if any if (!organizationLedgerUsesBitcoin) { foreach (FinancialTransactionRow row in ourTx.Rows) { if (addressAccountLookup.ContainsValue(row.Account.Identity)) // "ContainsValue" is bad, but n is low { // Do we have a foreign amount for this row already? Money foreignMoney = row.AmountForeignCents; if (foreignMoney == null || foreignMoney.Cents == 0) { // no we didn't; create one if (satoshisLookup.ContainsKey(row.AmountCents)) { row.AmountForeignCents = new Money(satoshisLookup[row.AmountCents], Currency.BitcoinCore, ourTx.DateTime); } else if (satoshisLookup.ContainsKey(-row.AmountCents)) // the negative counterpart { row.AmountForeignCents = new Money(-satoshisLookup[-row.AmountCents], Currency.BitcoinCore, ourTx.DateTime); } else { // There's a last case which may happen if the row is an addition to a previous row; if so, calculate row.AmountForeignCents = new Money(row.AmountCents, organization.Currency, ourTx.DateTime).ToCurrency(Currency.BitcoinCore); } } } } } } }