diff --git a/cmd/siac/main.go b/cmd/siac/main.go index c2c6328a63..c916f75b22 100644 --- a/cmd/siac/main.go +++ b/cmd/siac/main.go @@ -21,6 +21,7 @@ var ( renterDownloadAsync bool // Downloads files asynchronously renterListVerbose bool // Show additional info about uploaded files. renterShowHistory bool // Show download history in addition to download queue. + walletRawTxn bool // Encode/decode transactions in base64-encoded binary. ) var ( @@ -106,14 +107,16 @@ func main() { root.AddCommand(walletCmd) walletCmd.AddCommand(walletAddressCmd, walletAddressesCmd, walletChangepasswordCmd, walletInitCmd, walletInitSeedCmd, - walletLoadCmd, walletLockCmd, walletSeedsCmd, walletSendCmd, walletSweepCmd, - walletBalanceCmd, walletTransactionsCmd, walletUnlockCmd) + walletLoadCmd, walletLockCmd, walletSeedsCmd, walletSendCmd, walletSweepCmd, walletSignCmd, + walletBalanceCmd, walletBroadcastCmd, walletTransactionsCmd, walletUnlockCmd) walletInitCmd.Flags().BoolVarP(&initPassword, "password", "p", false, "Prompt for a custom password") walletInitCmd.Flags().BoolVarP(&initForce, "force", "", false, "destroy the existing wallet and re-encrypt") walletInitSeedCmd.Flags().BoolVarP(&initForce, "force", "", false, "destroy the existing wallet") walletLoadCmd.AddCommand(walletLoad033xCmd, walletLoadSeedCmd, walletLoadSiagCmd) walletSendCmd.AddCommand(walletSendSiacoinsCmd, walletSendSiafundsCmd) walletUnlockCmd.Flags().BoolVarP(&initPassword, "password", "p", false, "Display interactive password prompt even if SIA_WALLET_PASSWORD is set") + walletBroadcastCmd.Flags().BoolVarP(&walletRawTxn, "raw", "", false, "Decode transaction as base64 instead of JSON") + walletSignCmd.Flags().BoolVarP(&walletRawTxn, "raw", "", false, "Encode signed transaction as base64 instead of JSON") root.AddCommand(renterCmd) renterCmd.AddCommand(renterFilesDeleteCmd, renterFilesDownloadCmd, diff --git a/cmd/siac/walletcmd.go b/cmd/siac/walletcmd.go index e3123b275e..acc2219899 100644 --- a/cmd/siac/walletcmd.go +++ b/cmd/siac/walletcmd.go @@ -1,6 +1,8 @@ package main import ( + "encoding/base64" + "encoding/json" "errors" "fmt" "math" @@ -12,7 +14,12 @@ import ( "github.com/spf13/cobra" "golang.org/x/crypto/ssh/terminal" + "github.com/NebulousLabs/Sia/crypto" + "github.com/NebulousLabs/Sia/encoding" + "github.com/NebulousLabs/Sia/modules" + "github.com/NebulousLabs/Sia/modules/wallet" "github.com/NebulousLabs/Sia/types" + "github.com/NebulousLabs/entropy-mnemonics" ) var ( @@ -37,6 +44,13 @@ var ( Run: wrap(walletbalancecmd), } + walletBroadcastCmd = &cobra.Command{ + Use: "broadcast [txn]", + Short: "Broadcast a transaction", + Long: "Broadcast a transaction to connected peers. The transaction must be valid.", + Run: wrap(walletbroadcastcmd), + } + walletChangepasswordCmd = &cobra.Command{ Use: "change-password", Short: "Change the wallet password", @@ -148,6 +162,15 @@ Run 'wallet send --help' to see a list of available units.`, Run: wrap(walletsendsiafundscmd), } + walletSignCmd = &cobra.Command{ + Use: "sign [txn] [tosign]", + Short: "Sign a transaction", + Long: `Sign the specified inputs of a transaction. If siad is running with an +unlocked wallet, the /wallet/sign API call will be used. Otherwise, sign will +prompt for the wallet seed, and the signing key(s) will be regenerated.`, + Run: walletsigncmd, + } + walletSweepCmd = &cobra.Command{ Use: "sweep", Short: "Sweep siacoins and siafunds from a seed.", @@ -450,6 +473,29 @@ Estimated Fee: %v / KB fees.Maximum.Mul64(1e3).HumanString()) } +// walletbroadcastcmd broadcasts a transaction. +func walletbroadcastcmd(txnStr string) { + var txn types.Transaction + var err error + if walletRawTxn { + var txnBytes []byte + txnBytes, err = base64.StdEncoding.DecodeString(txnStr) + if err == nil { + err = encoding.Unmarshal(txnBytes, &txn) + } + } else { + err = json.Unmarshal([]byte(txnStr), &txn) + } + if err != nil { + die("Could not decode transaction:", err) + } + err = httpClient.TransactionPoolRawPost(txn, nil) + if err != nil { + die("Could not broadcast transaction:", err) + } + fmt.Println("Transaction broadcast successfully") +} + // walletsweepcmd sweeps coins and funds from a seed. func walletsweepcmd() { seed, err := passwordPrompt("Seed: ") @@ -464,6 +510,57 @@ func walletsweepcmd() { fmt.Printf("Swept %v and %v SF from seed.\n", currencyUnits(swept.Coins), swept.Funds) } +// walletsigncmd signs a transaction. +func walletsigncmd(cmd *cobra.Command, args []string) { + if len(args) < 1 || len(args) > 2 { + cmd.UsageFunc()(cmd) + os.Exit(exitCodeUsage) + } + + var txn types.Transaction + err := json.Unmarshal([]byte(args[0]), &txn) + if err != nil { + die("Invalid transaction:", err) + } + + var toSign []crypto.Hash + if len(args) == 2 { + err = json.Unmarshal([]byte(args[1]), &toSign) + if err != nil { + die("Invalid transaction:", err) + } + } + + // try API first + wspr, err := httpClient.WalletSignPost(txn, toSign) + if err == nil { + txn = wspr.Transaction + } else { + // fallback to offline keygen + fmt.Println("Signing via API failed: either siad is not running, or your wallet is locked.") + fmt.Println("Enter your wallet seed to generate the signing key(s) now and sign without siad.") + seedString, err := passwordPrompt("Seed: ") + if err != nil { + die("Reading seed failed:", err) + } + seed, err := modules.StringToSeed(seedString, mnemonics.English) + if err != nil { + die("Invalid seed:", err) + } + err = wallet.SignTransaction(&txn, seed, toSign) + if err != nil { + die("Failed to sign transaction:", err) + } + } + + if walletRawTxn { + base64.NewEncoder(base64.StdEncoding, os.Stdout).Write(encoding.Marshal(txn)) + } else { + json.NewEncoder(os.Stdout).Encode(txn) + } + fmt.Println() +} + // wallettransactionscmd lists all of the transactions related to the wallet, // providing a net flow of siacoins and siafunds for each. func wallettransactionscmd() { diff --git a/doc/API.md b/doc/API.md index b2362fdd4e..6e13dfcc7e 100644 --- a/doc/API.md +++ b/doc/API.md @@ -1263,6 +1263,7 @@ Wallet | [/wallet/address](#walletaddress-get) | GET | | [/wallet/addresses](#walletaddresses-get) | GET | | [/wallet/backup](#walletbackup-get) | GET | +| [/wallet/changepassword](#walletchangepassword-post) | POST | | [/wallet/init](#walletinit-post) | POST | | [/wallet/init/seed](#walletinitseed-post) | POST | | [/wallet/lock](#walletlock-post) | POST | @@ -1271,13 +1272,14 @@ Wallet | [/wallet/siacoins](#walletsiacoins-post) | POST | | [/wallet/siafunds](#walletsiafunds-post) | POST | | [/wallet/siagkey](#walletsiagkey-post) | POST | +| [/wallet/sign](#walletsign-post) | POST | | [/wallet/sweep/seed](#walletsweepseed-post) | POST | | [/wallet/transaction/:___id___](#wallettransactionid-get) | GET | | [/wallet/transactions](#wallettransactions-get) | GET | | [/wallet/transactions/:___addr___](#wallettransactionsaddr-get) | GET | | [/wallet/unlock](#walletunlock-post) | POST | -| [/wallet/verify/address/:___addr___](#walletverifyaddressaddr-get) | GET | -| [/wallet/changepassword](#walletchangepassword-post) | POST | +| [/wallet/unspent](#walletunspent-get) | GET | +| [/wallet/verify/address/:___addr___](#walletverifyaddress-get) | GET | For examples and detailed descriptions of request and response parameters, refer to [Wallet.md](/doc/api/Wallet.md). @@ -1367,6 +1369,20 @@ destination standard success or error response. See [#standard-responses](#standard-responses). +#### /wallet/changepassword [POST] + +changes the wallet's encryption key. + +###### Query String Parameters [(with comments)](/doc/api/Wallet.md#query-string-parameters-12) +``` +encryptionpassword +newpassword +``` + +###### Response +standard success or error response. See +[#standard-responses](#standard-responses). + #### /wallet/init [POST] initializes the wallet. After the wallet has been initialized once, it does @@ -1518,6 +1534,29 @@ keyfiles standard success or error response. See [#standard-responses](#standard-responses). +#### /wallet/sign [POST] + +Function: Sign a transaction. The wallet will attempt to sign each input +specified. + +###### Request Body +``` +{ + "transaction": { }, // types.Transaction + "tosign": [ + "1234567890abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "abcdef0123456789abcdef0123456789abcd1234567890ef0123456789abcdef" + ] +} +``` + +###### Response +```javascript +{ + "transaction": { } // types.Transaction +} +``` + #### /wallet/sweep/seed [POST] Function: Scan the blockchain for outputs belonging to a seed and send them to @@ -1650,28 +1689,38 @@ encryptionpassword standard success or error response. See [#standard-responses](#standard-responses). -#### /wallet/verify/address/:addr [GET] -takes the address specified by :addr and returns a JSON response indicating if the address is valid. +#### /wallet/unspent [GET] + +returns a list of outputs that the wallet can spend. ###### JSON Response [(with comments)](/doc/api/Wallet.md#json-response-11) ```javascript { - "valid": true + "outputs": [ + { + "id": "1234567890abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "fundtype": "siacoin output", + "confirmationheight": 50000, + "unlockconditions": { + "publickeys": [{"algorithm":"ed25519","key":"/XUGj8PxMDkqdae6Js6ubcERxfxnXN7XPjZyANBZH1I="}], + "signaturesrequired": 1 + }, + "unlockhash": "1234567890abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789ab", + "value": "1234" // big int + } + ] } ``` -#### /wallet/changepassword [POST] +#### /wallet/verify/address/:addr [GET] -changes the wallet's encryption key. +takes the address specified by :addr and returns a JSON response indicating if the address is valid. -###### Query String Parameters [(with comments)](/doc/api/Wallet.md#query-string-parameters-12) -``` -encryptionpassword -newpassword +###### JSON Response [(with comments)](/doc/api/Wallet.md#json-response-11) +```javascript +{ + "valid": true +} ``` -###### Response -standard success or error response. See -[#standard-responses](#standard-responses). - diff --git a/doc/api/Transactionpool.md b/doc/api/Transactionpool.md index 4a9eecc07e..645852abf8 100644 --- a/doc/api/Transactionpool.md +++ b/doc/api/Transactionpool.md @@ -71,7 +71,7 @@ returns the ID for the requested transaction and its raw encoded parents and tra submits a raw transaction to the transaction pool, broadcasting it to the transaction pool's peers. -###### Query String Parameters [(with comments)](/doc/api/Transactionpool.md#query-string-parameters) +###### Query String Parameters ``` parents string // raw base64 encoded transaction parents diff --git a/doc/api/Wallet.md b/doc/api/Wallet.md index 666f6b42c6..359aaa4146 100644 --- a/doc/api/Wallet.md +++ b/doc/api/Wallet.md @@ -34,6 +34,7 @@ Index | [/wallet/address](#walletaddress-get) | GET | | [/wallet/addresses](#walletaddresses-get) | GET | | [/wallet/backup](#walletbackup-get) | GET | +| [/wallet/changepassword](#walletchangepassword-post) | POST | | [/wallet/init](#walletinit-post) | POST | | [/wallet/init/seed](#walletinitseed-post) | POST | | [/wallet/lock](#walletlock-post) | POST | @@ -42,13 +43,14 @@ Index | [/wallet/siacoins](#walletsiacoins-post) | POST | | [/wallet/siafunds](#walletsiafunds-post) | POST | | [/wallet/siagkey](#walletsiagkey-post) | POST | +| [/wallet/sign](#walletsign-post) | POST | | [/wallet/sweep/seed](#walletsweepseed-post) | POST | | [/wallet/transaction/___:id___](#wallettransactionid-get) | GET | | [/wallet/transactions](#wallettransactions-get) | GET | | [/wallet/transactions/___:addr___](#wallettransactionsaddr-get) | GET | | [/wallet/unlock](#walletunlock-post) | POST | +| [/wallet/unspent](#walletunspent-get) | GET | | [/wallet/verify/address/:___addr___](#walletverifyaddress-get) | GET | -| [/wallet/changepassword](#walletchangepassword-post) | POST | #### /wallet [GET] @@ -180,6 +182,22 @@ destination standard success or error response. See [API.md#standard-responses](/doc/API.md#standard-responses). +#### /wallet/changepassword [POST] + +changes the wallet's encryption password. + +###### Query String Parameter +``` +// encryptionpassword is the wallet's current encryption password. +encryptionpassword +// newpassword is the new password for the wallet. +newpassword +``` + +###### Response +standard success or error response. See +[#standard-responses](#standard-responses). + #### /wallet/init [POST] initializes the wallet. After the wallet has been initialized once, it does not @@ -483,6 +501,33 @@ keyfiles standard success or error response. See [API.md#standard-responses](/doc/API.md#standard-responses). +#### /wallet/sign [POST] + +Function: Sign a transaction. The wallet will attempt to sign each input +specified. + +###### Request Body +``` +{ + // unsigned transaction + "transaction": { }, // types.Transaction + + // inputs to sign; a mapping from OutputID to UnlockHash + "tosign": { + "1234567890abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "abcdef0123456789abcdef0123456789abcd1234567890ef0123456789abcdef" + } +} +``` + +###### Response +```javascript +{ + // signed transaction + "transaction": { } // types.Transaction +} +``` + #### /wallet/sweep/seed [POST] Function: Scan the blockchain for outputs belonging to a seed and send them to @@ -693,6 +738,49 @@ encryptionpassword string standard success or error response. See [API.md#standard-responses](/doc/API.md#standard-responses). +#### /wallet/unspent [GET] + +returns a list of outputs that the wallet can spend. + +###### Response +```javascript +{ + // Array of outputs that the wallet can spend. + "outputs": [ + { + // The id of the output. + "id": "1234567890abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + + // Type of output, either 'siacoin output' or 'siafund output'. + "fundtype": "siacoin output", + + // Height of block in which the output appeared. To calculate the + // number of confirmations, subtract this number from the current + // block height. + "confirmationheight": 50000, + + // Unlock conditions of the output. These conditions must be met in + // order to spend the output. + "unlockconditions": { + "timelock": 0, + "publickeys": [{ + "algorithm": "ed25519", + "key": "/XUGj8PxMDkqdae6Js6ubcERxfxnXN7XPjZyANBZH1I=" + }], + "signaturesrequired": 1 + }, + + // Hash of the unlock conditions, commonly known as the "address". + "unlockhash": "1234567890abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789ab", + + // Amount of funds in the output; hastings for siacoin outputs, and + // siafunds for siafund outputs. + "value": "1234" // big int + } + ] +} +``` + #### /wallet/verify/address/:addr [GET] takes the address specified by :addr and returns a JSON response indicating if the address is valid. @@ -704,19 +792,3 @@ takes the address specified by :addr and returns a JSON response indicating if t "valid": true } ``` - -#### /wallet/changepassword [POST] - -changes the wallet's encryption password. - -###### Query String Parameter -``` -// encryptionpassword is the wallet's current encryption password. -encryptionpassword -// newpassword is the new password for the wallet. -newpassword -``` - -###### Response -standard success or error response. See -[#standard-responses](#standard-responses). diff --git a/modules/wallet.go b/modules/wallet.go index 46ab24c04f..b2bda70bd2 100644 --- a/modules/wallet.go +++ b/modules/wallet.go @@ -104,6 +104,17 @@ type ( Outputs []ProcessedOutput `json:"outputs"` } + // A SpendableOutput is a SiacoinOutput or SiafundOutput that the wallet + // can spend. + SpendableOutput struct { + ID types.OutputID `json:"id"` + FundType types.Specifier `json:"fundtype"` + UnlockConditions types.UnlockConditions `json:"unlockconditions"` + UnlockHash types.UnlockHash `json:"unlockhash"` + Value types.Currency `json:"value"` + ConfirmationHeight types.BlockHeight `json:"confirmationheight"` + } + // TransactionBuilder is used to construct custom transactions. A transaction // builder is initialized via 'RegisterTransaction' and then can be modified by // adding funds or other fields. The transaction is completed by calling @@ -416,6 +427,14 @@ type ( // DustThreshold returns the quantity per byte below which a Currency is // considered to be Dust. DustThreshold() (types.Currency, error) + + // SpendableOutputs returns the outputs spendable by the wallet. + SpendableOutputs() []SpendableOutput + + // SignTransaction signs txn using secret keys known to the wallet. + // The transaction should be complete with the exception of the + // Signature fields of each TransactionSignature referenced by toSign. + SignTransaction(txn *types.Transaction, toSign []crypto.Hash) error } // WalletSettings control the behavior of the Wallet. diff --git a/modules/wallet/transactionbuilder.go b/modules/wallet/transactionbuilder.go index 4fe1d4de66..25f1360027 100644 --- a/modules/wallet/transactionbuilder.go +++ b/modules/wallet/transactionbuilder.go @@ -3,6 +3,7 @@ package wallet import ( "bytes" "errors" + "math" "sort" "github.com/NebulousLabs/Sia/crypto" @@ -682,3 +683,223 @@ func (w *Wallet) StartTransaction() (modules.TransactionBuilder, error) { defer w.tg.Done() return w.RegisterTransaction(types.Transaction{}, nil) } + +// SpendableOutputs returns the outputs spendable by the wallet. +func (w *Wallet) SpendableOutputs() []modules.SpendableOutput { + w.mu.Lock() + defer w.mu.Unlock() + // ensure durability of reported outputs + w.syncDB() + + // build initial list of confirmed outputs + var outputs []modules.SpendableOutput + dbForEachSiacoinOutput(w.dbTx, func(scoid types.SiacoinOutputID, sco types.SiacoinOutput) { + outputs = append(outputs, modules.SpendableOutput{ + FundType: types.SpecifierSiacoinOutput, + ID: types.OutputID(scoid), + UnlockHash: sco.UnlockHash, + Value: sco.Value, + }) + }) + dbForEachSiafundOutput(w.dbTx, func(sfoid types.SiafundOutputID, sfo types.SiafundOutput) { + outputs = append(outputs, modules.SpendableOutput{ + FundType: types.SpecifierSiafundOutput, + ID: types.OutputID(sfoid), + UnlockHash: sfo.UnlockHash, + Value: sfo.Value, + }) + }) + + // don't include outputs marked as spent in pending transactions + pending := make(map[types.OutputID]struct{}) + for _, pt := range w.unconfirmedProcessedTransactions { + for _, input := range pt.Inputs { + if input.WalletAddress { + pending[input.ParentID] = struct{}{} + } + } + } + filtered := outputs[:0] + for _, o := range outputs { + if _, ok := pending[o.ID]; !ok { + filtered = append(filtered, o) + } + } + outputs = filtered + + // set the confirmation height for each output +outer: + for i, o := range outputs { + txnIndices, _ := dbGetAddrTransactions(w.dbTx, o.UnlockHash) + for _, j := range txnIndices { + pt, err := dbGetProcessedTransaction(w.dbTx, j) + if err != nil { + continue + } + for _, sco := range pt.Outputs { + if sco.ID == o.ID { + outputs[i].ConfirmationHeight = pt.ConfirmationHeight + continue outer + } + } + } + } + + // add unconfirmed outputs, except those that are spent in pending + // transactions + for _, pt := range w.unconfirmedProcessedTransactions { + for _, o := range pt.Outputs { + if _, ok := pending[o.ID]; !ok && o.WalletAddress { + outputs = append(outputs, modules.SpendableOutput{ + FundType: types.SpecifierSiacoinOutput, + ID: o.ID, + UnlockHash: o.RelatedAddress, + Value: o.Value, + ConfirmationHeight: types.BlockHeight(math.MaxUint64), // unconfirmed + }) + } + } + } + + // add UnlockConditions for each output. If we don't know the + // UnlockConditions, they aren't actually spendable. + filtered = outputs[:0] + for _, o := range outputs { + if sk, ok := w.keys[o.UnlockHash]; ok { + o.UnlockConditions = sk.UnlockConditions + filtered = append(filtered, o) + } + } + outputs = filtered + + return outputs +} + +// SignTransaction signs txn using secret keys known to the wallet. The +// transaction should be complete with the exception of the Signature fields +// of each TransactionSignature referenced by toSign. For convenience, if +// toSign is empty, SignTransaction signs everything that it can. +func (w *Wallet) SignTransaction(txn *types.Transaction, toSign []crypto.Hash) error { + w.mu.Lock() + defer w.mu.Unlock() + if !w.unlocked { + return modules.ErrLockedWallet + } + // if toSign is empty, sign all inputs that we have keys for + if len(toSign) == 0 { + for _, sci := range txn.SiacoinInputs { + if _, ok := w.keys[sci.UnlockConditions.UnlockHash()]; ok { + toSign = append(toSign, crypto.Hash(sci.ParentID)) + } + } + for _, sfi := range txn.SiafundInputs { + if _, ok := w.keys[sfi.UnlockConditions.UnlockHash()]; ok { + toSign = append(toSign, crypto.Hash(sfi.ParentID)) + } + } + } + return signTransaction(txn, w.keys, toSign) +} + +// SignTransaction signs txn using secret keys derived from seed. The +// transaction should be complete with the exception of the Signature fields +// of each TransactionSignature referenced by toSign, which must not be empty. +// +// SignTransaction must derive all of the keys from scratch, so it is +// appreciably slower than calling the Wallet.SignTransaction method. Only the +// first 1 million keys are derived. +func SignTransaction(txn *types.Transaction, seed modules.Seed, toSign []crypto.Hash) error { + if len(toSign) == 0 { + // unlike the wallet method, we can't simply "sign all inputs we have + // keys for," because without generating all of the keys up front, we + // don't know how many inputs we actually have keys for. + return errors.New("toSign cannot be empty") + } + // generate keys in batches up to 1e6 before giving up + keys := make(map[types.UnlockHash]spendableKey, 1e6) + var keyIndex uint64 + const keysPerBatch = 1000 + for len(keys) < 1e6 { + for _, sk := range generateKeys(seed, keyIndex, keyIndex+keysPerBatch) { + keys[sk.UnlockConditions.UnlockHash()] = sk + } + keyIndex += keysPerBatch + if err := signTransaction(txn, keys, toSign); err == nil { + return nil + } + } + return signTransaction(txn, keys, toSign) +} + +// signTransaction signs the specified inputs of txn using the specified keys. +// It returns an error if any of the specified inputs cannot be signed. +func signTransaction(txn *types.Transaction, keys map[types.UnlockHash]spendableKey, toSign []crypto.Hash) error { + // helper function to lookup unlock conditions in the txn associated with + // a transaction signature's ParentID + findUnlockConditions := func(id crypto.Hash) (types.UnlockConditions, bool) { + for _, sci := range txn.SiacoinInputs { + if crypto.Hash(sci.ParentID) == id { + return sci.UnlockConditions, true + } + } + for _, sfi := range txn.SiafundInputs { + if crypto.Hash(sfi.ParentID) == id { + return sfi.UnlockConditions, true + } + } + return types.UnlockConditions{}, false + } + // helper function to lookup the secret key that can sign + findSigningKey := func(uc types.UnlockConditions, pubkeyIndex uint64) (crypto.SecretKey, bool) { + if pubkeyIndex >= uint64(len(uc.PublicKeys)) { + return crypto.SecretKey{}, false + } + pk := uc.PublicKeys[pubkeyIndex] + sk, ok := keys[uc.UnlockHash()] + if !ok { + return crypto.SecretKey{}, false + } + for _, key := range sk.SecretKeys { + pubKey := key.PublicKey() + if bytes.Equal(pk.Key, pubKey[:]) { + return key, true + } + } + return crypto.SecretKey{}, false + } + + for _, id := range toSign { + // find associated txn signature + // + // NOTE: it's possible that the Signature field will already be filled + // out. Although we could save a bit of work by not signing it, in + // practice it's probably best to overwrite any existing signatures, + // since we know that ours will be valid. + sigIndex := -1 + for i, sig := range txn.TransactionSignatures { + if sig.ParentID == id { + sigIndex = i + break + } + } + if sigIndex == -1 { + return errors.New("toSign references signatures not present in transaction") + } + // find associated input + uc, ok := findUnlockConditions(id) + if !ok { + return errors.New("toSign references IDs not present in transaction") + } + // lookup the signing key + sk, ok := findSigningKey(uc, txn.TransactionSignatures[sigIndex].PublicKeyIndex) + if !ok { + return errors.New("could not locate signing key for " + id.String()) + } + // add signature + sigHash := txn.SigHash(sigIndex) + encodedSig := crypto.SignHash(sigHash, sk) + txn.TransactionSignatures[sigIndex].Signature = encodedSig[:] + } + + return nil +} diff --git a/modules/wallet/transactionbuilder_test.go b/modules/wallet/transactionbuilder_test.go index 52e33260f9..33d4526ef1 100644 --- a/modules/wallet/transactionbuilder_test.go +++ b/modules/wallet/transactionbuilder_test.go @@ -4,6 +4,7 @@ import ( "sync" "testing" + "github.com/NebulousLabs/Sia/crypto" "github.com/NebulousLabs/Sia/modules" "github.com/NebulousLabs/Sia/types" ) @@ -508,6 +509,72 @@ func TestParallelBuilders(t *testing.T) { } } +// TestSignTransaction constructs a valid, signed transaction using the +// wallet's SpendableOutputs and SignTransaction methods. +func TestSignTransaction(t *testing.T) { + if testing.Short() { + t.SkipNow() + } + wt, err := createWalletTester(t.Name(), modules.ProdDependencies) + if err != nil { + t.Fatal(err) + } + defer wt.closeWt() + + // get an output to spend + outputs := wt.wallet.SpendableOutputs() + + // create a transaction that sends an output to the void + txn := types.Transaction{ + SiacoinInputs: []types.SiacoinInput{{ + ParentID: types.SiacoinOutputID(outputs[0].ID), + UnlockConditions: outputs[0].UnlockConditions, + }}, + SiacoinOutputs: []types.SiacoinOutput{{ + Value: outputs[0].Value, + UnlockHash: types.UnlockHash{}, + }}, + TransactionSignatures: []types.TransactionSignature{{ + ParentID: crypto.Hash(outputs[0].ID), + }}, + } + + // sign the transaction + err = wt.wallet.SignTransaction(&txn, nil) + if err != nil { + t.Fatal(err) + } + // txn should now have unlock condictions and a signature + if txn.SiacoinInputs[0].UnlockConditions.SignaturesRequired == 0 { + t.Fatal("unlock conditions are still unset") + } + if len(txn.TransactionSignatures) == 0 { + t.Fatal("transaction was not signed") + } + + // the resulting transaction should be valid; submit it to the tpool and + // mine a block to confirm it + height, _ := wt.wallet.Height() + err = txn.StandaloneValid(height) + if err != nil { + t.Fatal(err) + } + err = wt.tpool.AcceptTransactionSet([]types.Transaction{txn}) + if err != nil { + t.Fatal(err) + } + wt.addBlockNoPayout() + + // the wallet should no longer list the resulting output as spendable + outputs = wt.wallet.SpendableOutputs() + if len(outputs) != 1 { + t.Fatal("expected one output") + } + if outputs[0].ID == types.OutputID(txn.SiacoinInputs[0].ParentID) { + t.Fatal("spent output still listed as spendable") + } +} + // TestUnconfirmedParents tests the functionality of the transaction builder's // UnconfirmedParents method. func TestUnconfirmedParents(t *testing.T) { diff --git a/modules/wallet/wallet.go b/modules/wallet/wallet.go index acf9ae4eb4..6cf751d663 100644 --- a/modules/wallet/wallet.go +++ b/modules/wallet/wallet.go @@ -122,6 +122,7 @@ func (w *Wallet) Height() (types.BlockHeight, error) { w.mu.Lock() defer w.mu.Unlock() + w.syncDB() var height uint64 err := w.db.View(func(tx *bolt.Tx) error { diff --git a/node/api/client/transactionpool.go b/node/api/client/transactionpool.go index d9228e5a91..19464cd95d 100644 --- a/node/api/client/transactionpool.go +++ b/node/api/client/transactionpool.go @@ -1,6 +1,7 @@ package client import ( + "encoding/base64" "net/url" "github.com/NebulousLabs/Sia/encoding" @@ -8,6 +9,18 @@ import ( "github.com/NebulousLabs/Sia/types" ) +// TransactionpoolRawPost uses the /tpool/raw endpoint to broadcast a +// transaction by adding it to the transactionpool. +func (c *Client) TransactionpoolRawPost(parents []types.Transaction, txn types.Transaction) (err error) { + parentsBytes := encoding.Marshal(parents) + txnBytes := encoding.Marshal(txn) + values := url.Values{} + values.Set("parents", base64.StdEncoding.EncodeToString(parentsBytes)) + values.Set("transaction", base64.StdEncoding.EncodeToString(txnBytes)) + err = c.post("/tpool/raw", values.Encode(), nil) + return +} + // TransactionPoolFeeGet uses the /tpool/fee endpoint to get a fee estimation. func (c *Client) TransactionPoolFeeGet() (tfg api.TpoolFeeGET, err error) { err = c.get("/tpool/fee", &tfg) @@ -16,7 +29,7 @@ func (c *Client) TransactionPoolFeeGet() (tfg api.TpoolFeeGET, err error) { // TransactionPoolRawPost uses the /tpool/raw endpoint to send a raw // transaction to the transaction pool. -func (c *Client) TransactionPoolRawPost(txn types.Transaction, parents types.Transaction) (err error) { +func (c *Client) TransactionPoolRawPost(txn types.Transaction, parents []types.Transaction) (err error) { values := url.Values{} values.Set("transaction", string(encoding.Marshal(txn))) values.Set("parents", string(encoding.Marshal(parents))) diff --git a/node/api/client/wallet.go b/node/api/client/wallet.go index 145e8113fd..2a8fb7a1e9 100644 --- a/node/api/client/wallet.go +++ b/node/api/client/wallet.go @@ -1,11 +1,13 @@ package client import ( + "bytes" "encoding/json" "fmt" "net/url" "strconv" + "github.com/NebulousLabs/Sia/crypto" "github.com/NebulousLabs/Sia/node/api" "github.com/NebulousLabs/Sia/types" ) @@ -106,6 +108,20 @@ func (c *Client) WalletSiacoinsPost(amount types.Currency, destination types.Unl return } +// WalletSignPost uses the /wallet/sign api endpoint to sign a transaction. +func (c *Client) WalletSignPost(txn types.Transaction, toSign []crypto.Hash) (wspr api.WalletSignPOSTResp, err error) { + buf := new(bytes.Buffer) + err = json.NewEncoder(buf).Encode(api.WalletSignPOSTParams{ + Transaction: txn, + ToSign: toSign, + }) + if err != nil { + return + } + err = c.post("/wallet/sign", string(buf.Bytes()), &wspr) + return +} + // WalletSiafundsPost uses the /wallet/siafunds api endpoint to send siafunds // to a single address. func (c *Client) WalletSiafundsPost(amount types.Currency, destination types.UnlockHash) (wsp api.WalletSiafundsPOST, err error) { @@ -159,6 +175,13 @@ func (c *Client) WalletUnlockPost(password string) (err error) { return } +// WalletUnspentGet requests the /wallet/unspent endpoint and returns all of +// the unspent outputs related to the wallet. +func (c *Client) WalletUnspentGet() (wug api.WalletUnspentGET, err error) { + err = c.get("/wallet/unspent", &wug) + return +} + // Wallet033xPost uses the /wallet/033x endpoint to load a v0.3.3.x wallet into // the current wallet. func (c *Client) Wallet033xPost(path, password string) (err error) { diff --git a/node/api/routes.go b/node/api/routes.go index 31bc083ab6..f4fce16674 100644 --- a/node/api/routes.go +++ b/node/api/routes.go @@ -130,6 +130,8 @@ func (api *API) buildHTTPRoutes(requiredUserAgent string, requiredPassword strin router.GET("/wallet/verify/address/:addr", api.walletVerifyAddressHandler) router.POST("/wallet/unlock", RequirePassword(api.walletUnlockHandler, requiredPassword)) router.POST("/wallet/changepassword", RequirePassword(api.walletChangePasswordHandler, requiredPassword)) + router.GET("/wallet/unspent", RequirePassword(api.walletUnspentHandler, requiredPassword)) + router.POST("/wallet/sign", RequirePassword(api.walletSignHandler, requiredPassword)) } // Apply UserAgent middleware and return the Router diff --git a/node/api/wallet.go b/node/api/wallet.go index 112a68b0c3..60fb1c4ddd 100644 --- a/node/api/wallet.go +++ b/node/api/wallet.go @@ -65,6 +65,18 @@ type ( TransactionIDs []types.TransactionID `json:"transactionids"` } + // WalletSignPOSTParams contains the unsigned transaction and a set of + // inputs to sign. + WalletSignPOSTParams struct { + Transaction types.Transaction `json:"transaction"` + ToSign []crypto.Hash `json:"tosign"` + } + + // WalletSignPOSTResp contains the signed transaction. + WalletSignPOSTResp struct { + Transaction types.Transaction `json:"transaction"` + } + // WalletSeedsGET contains the seeds used by the wallet. WalletSeedsGET struct { PrimarySeed string `json:"primaryseed"` @@ -100,6 +112,13 @@ type ( UnconfirmedTransactions []modules.ProcessedTransaction `json:"unconfirmedtransactions"` } + // WalletUnspentGET contains the unspent outputs of the wallet. The + // MaturityHeight field of each output indicates the height of the block + // that the output appeared in. + WalletUnspentGET struct { + Outputs []modules.SpendableOutput `json:"outputs"` + } + // WalletVerifyAddressGET contains a bool indicating if the address passed to // /wallet/verify/address/:addr is a valid address. WalletVerifyAddressGET struct { @@ -658,3 +677,28 @@ func (api *API) walletVerifyAddressHandler(w http.ResponseWriter, req *http.Requ err := new(types.UnlockHash).LoadString(addrString) WriteJSON(w, WalletVerifyAddressGET{Valid: err == nil}) } + +// walletUnspentHandler handles API calls to /wallet/unspent. +func (api *API) walletUnspentHandler(w http.ResponseWriter, req *http.Request, _ httprouter.Params) { + WriteJSON(w, WalletUnspentGET{ + Outputs: api.wallet.SpendableOutputs(), + }) +} + +// walletSignHandler handles API calls to /wallet/sign. +func (api *API) walletSignHandler(w http.ResponseWriter, req *http.Request, _ httprouter.Params) { + var params WalletSignPOSTParams + err := json.NewDecoder(req.Body).Decode(¶ms) + if err != nil { + WriteError(w, Error{"invalid parameters: " + err.Error()}, http.StatusBadRequest) + return + } + err = api.wallet.SignTransaction(¶ms.Transaction, params.ToSign) + if err != nil { + WriteError(w, Error{"failed to sign transaction: " + err.Error()}, http.StatusBadRequest) + return + } + WriteJSON(w, WalletSignPOSTResp{ + Transaction: params.Transaction, + }) +} diff --git a/siatest/wallet/wallet_test.go b/siatest/wallet/wallet_test.go index 7b31a20176..f3214f105b 100644 --- a/siatest/wallet/wallet_test.go +++ b/siatest/wallet/wallet_test.go @@ -7,6 +7,8 @@ import ( "time" "github.com/NebulousLabs/Sia/build" + "github.com/NebulousLabs/Sia/crypto" + "github.com/NebulousLabs/Sia/node" "github.com/NebulousLabs/Sia/siatest" "github.com/NebulousLabs/Sia/types" ) @@ -110,3 +112,84 @@ func TestTransactionReorg(t *testing.T) { t.Fatal(err) } } + +// TestSignTransaction is a integration test for signing transaction offline +// using the API. +func TestSignTransaction(t *testing.T) { + if testing.Short() { + t.SkipNow() + } + + testdir, err := siatest.TestDir(t.Name()) + if err != nil { + t.Fatal(err) + } + + // Create a new server + testNode, err := siatest.NewNode(node.AllModules(testdir)) + if err != nil { + t.Fatal(err) + } + defer func() { + if err := testNode.Close(); err != nil { + t.Fatal(err) + } + }() + + // get an output to spend + unspentResp, err := testNode.WalletUnspentGet() + if err != nil { + t.Fatal("failed to get spendable outputs") + } + outputs := unspentResp.Outputs + + // create a transaction that sends an output to the void + txn := types.Transaction{ + SiacoinInputs: []types.SiacoinInput{{ + ParentID: types.SiacoinOutputID(outputs[0].ID), + UnlockConditions: outputs[0].UnlockConditions, + }}, + SiacoinOutputs: []types.SiacoinOutput{{ + Value: outputs[0].Value, + UnlockHash: types.UnlockHash{}, + }}, + TransactionSignatures: []types.TransactionSignature{{ + ParentID: crypto.Hash(outputs[0].ID), + }}, + } + + // sign the transaction + signResp, err := testNode.WalletSignPost(txn, nil) + if err != nil { + t.Fatal("failed to sign the transaction", err) + } + txn = signResp.Transaction + + // txn should now have unlock condictions and a signature + if txn.SiacoinInputs[0].UnlockConditions.SignaturesRequired == 0 { + t.Fatal("unlock conditions are still unset") + } + if len(txn.TransactionSignatures) == 0 { + t.Fatal("transaction was not signed") + } + + // the resulting transaction should be valid; submit it to the tpool and + // mine a block to confirm it + if err := testNode.TransactionpoolRawPost(nil, txn); err != nil { + t.Fatal("failed to add transaction to pool", err) + } + if err := testNode.MineBlock(); err != nil { + t.Fatal("failed to mine block", err) + } + + // the wallet should no longer list the resulting output as spendable + unspentResp, err = testNode.WalletUnspentGet() + if err != nil { + t.Fatal("failed to get spendable outputs") + } + for _, output := range unspentResp.Outputs { + if output.ID == types.OutputID(txn.SiacoinInputs[0].ParentID) { + t.Fatal("spent output still listed as spendable") + } + } +} diff --git a/types/encoding.go b/types/encoding.go index e155073f0d..3599b33356 100644 --- a/types/encoding.go +++ b/types/encoding.go @@ -476,7 +476,7 @@ func (fcid *FileContractID) UnmarshalJSON(b []byte) error { // MarshalJSON marshals an id as a hex string. func (oid OutputID) MarshalJSON() ([]byte, error) { - return json.Marshal(oid.String()) + return (*crypto.Hash)(&oid).MarshalJSON() } // String prints the id in hex. @@ -484,6 +484,16 @@ func (oid OutputID) String() string { return fmt.Sprintf("%x", oid[:]) } +// MarshalText marshals an OutputID to text. +func (oid OutputID) MarshalText() (text []byte, err error) { + return []byte(oid.String()), nil +} + +// UnmarshalText unmarshals an OutputID from text. +func (oid *OutputID) UnmarshalText(text []byte) error { + return (*crypto.Hash)(oid).LoadString(string(text)) +} + // UnmarshalJSON decodes the json hex string of the id. func (oid *OutputID) UnmarshalJSON(b []byte) error { return (*crypto.Hash)(oid).UnmarshalJSON(b) diff --git a/types/signatures.go b/types/signatures.go index da2441e656..0a3a45766f 100644 --- a/types/signatures.go +++ b/types/signatures.go @@ -382,19 +382,12 @@ func (t *Transaction) validSignatures(currentHeight BlockHeight) error { case SignatureEd25519: // Decode the public key and signature. var edPK crypto.PublicKey - err := encoding.Unmarshal([]byte(publicKey.Key), &edPK) - if err != nil { - return err - } - var edSig [crypto.SignatureSize]byte - err = encoding.Unmarshal([]byte(sig.Signature), &edSig) - if err != nil { - return err - } - cryptoSig := crypto.Signature(edSig) + copy(edPK[:], publicKey.Key) + var edSig crypto.Signature + copy(edSig[:], sig.Signature) sigHash := t.SigHash(i) - err = crypto.VerifyHash(sigHash, edPK, cryptoSig) + err = crypto.VerifyHash(sigHash, edPK, edSig) if err != nil { return err }