diff --git a/api/api.go b/api/api.go index 28c20d4..c7fbcde 100644 --- a/api/api.go +++ b/api/api.go @@ -1,6 +1,7 @@ package api import ( + "encoding/json" "time" "go.sia.tech/core/types" @@ -31,36 +32,37 @@ type TxpoolTransactionsResponse struct { V2Transactions []types.V2Transaction `json:"v2transactions"` } -// BalanceResponse is the response type for /wallets/:name/balance. +// BalanceResponse is the response type for /wallets/:id/balance. type BalanceResponse wallet.Balance -// WalletOutputsResponse is the response type for /wallets/:name/outputs. -type WalletOutputsResponse struct { - SiacoinOutputs []types.SiacoinElement `json:"siacoinOutputs"` - SiafundOutputs []types.SiafundElement `json:"siafundOutputs"` -} - -// WalletReserveRequest is the request type for /wallets/:name/reserve. +// WalletReserveRequest is the request type for /wallets/:id/reserve. type WalletReserveRequest struct { SiacoinOutputs []types.SiacoinOutputID `json:"siacoinOutputs"` SiafundOutputs []types.SiafundOutputID `json:"siafundOutputs"` Duration time.Duration `json:"duration"` } -// WalletReleaseRequest is the request type for /wallets/:name/release. +// A WalletUpdateRequest is a request to update a wallet +type WalletUpdateRequest struct { + Name string `json:"name"` + Description string `json:"description"` + Metadata json.RawMessage `json:"metadata"` +} + +// WalletReleaseRequest is the request type for /wallets/:id/release. type WalletReleaseRequest struct { SiacoinOutputs []types.SiacoinOutputID `json:"siacoinOutputs"` SiafundOutputs []types.SiafundOutputID `json:"siafundOutputs"` } -// WalletFundRequest is the request type for /wallets/:name/fund. +// WalletFundRequest is the request type for /wallets/:id/fund. type WalletFundRequest struct { Transaction types.Transaction `json:"transaction"` Amount types.Currency `json:"amount"` ChangeAddress types.Address `json:"changeAddress"` } -// WalletFundSFRequest is the request type for /wallets/:name/fundsf. +// WalletFundSFRequest is the request type for /wallets/:id/fundsf. type WalletFundSFRequest struct { Transaction types.Transaction `json:"transaction"` Amount uint64 `json:"amount"` @@ -68,7 +70,7 @@ type WalletFundSFRequest struct { ClaimAddress types.Address `json:"claimAddress"` } -// WalletFundResponse is the response type for /wallets/:name/fund. +// WalletFundResponse is the response type for /wallets/:id/fund. type WalletFundResponse struct { Transaction types.Transaction `json:"transaction"` ToSign []types.Hash256 `json:"toSign"` diff --git a/api/api_test.go b/api/api_test.go index 59c40fa..ba8dcda 100644 --- a/api/api_test.go +++ b/api/api_test.go @@ -80,10 +80,11 @@ func TestWallet(t *testing.T) { sav := wallet.NewSeedAddressVault(wallet.NewSeed(), 0, 20) c, shutdown := runServer(cm, nil, wm) defer shutdown() - if err := c.AddWallet("primary", nil); err != nil { + w, err := c.AddWallet(api.WalletUpdateRequest{Name: "primary"}) + if err != nil { t.Fatal(err) } - wc := c.Wallet("primary") + wc := c.Wallet(w.ID) if err := c.Resubscribe(0); err != nil { t.Fatal(err) } @@ -112,8 +113,8 @@ func TestWallet(t *testing.T) { } // create and add an address - addr, info := sav.NewAddress("primary") - if err := wc.AddAddress(addr, info); err != nil { + addr := sav.NewAddress("primary") + if err := wc.AddAddress(addr); err != nil { t.Fatal(err) } @@ -121,8 +122,10 @@ func TestWallet(t *testing.T) { addresses, err = wc.Addresses() if err != nil { t.Fatal(err) - } else if _, ok := addresses[addr]; !ok || len(addresses) != 1 { - t.Fatal("bad address list", addresses) + } else if len(addresses) != 1 { + t.Fatal("address list should have one address") + } else if addresses[0].Address != addr.Address { + t.Fatalf("address should be %v, got %v", addr, addresses[0]) } // send gift to wallet @@ -133,8 +136,8 @@ func TestWallet(t *testing.T) { UnlockConditions: types.StandardUnlockConditions(giftPrivateKey.PublicKey()), }}, SiacoinOutputs: []types.SiacoinOutput{ - {Address: addr, Value: types.Siacoins(1).Div64(2)}, - {Address: addr, Value: types.Siacoins(1).Div64(2)}, + {Address: addr.Address, Value: types.Siacoins(1).Div64(2)}, + {Address: addr.Address, Value: types.Siacoins(1).Div64(2)}, }, Signatures: []types.TransactionSignature{{ ParentID: types.Hash256(giftSCOID), @@ -176,7 +179,7 @@ func TestWallet(t *testing.T) { t.Error("transaction should appear in history") } - outputs, _, err := wc.Outputs() + outputs, err := wc.SiacoinOutputs(0, 100) if err != nil { t.Fatal(err) } else if len(outputs) != 2 { @@ -188,7 +191,7 @@ func TestWallet(t *testing.T) { b = types.Block{ ParentID: cs.Index.ID, Timestamp: types.CurrentTimestamp(), - MinerPayouts: []types.SiacoinOutput{{Address: addr, Value: cs.BlockReward()}}, + MinerPayouts: []types.SiacoinOutput{{Address: addr.Address, Value: cs.BlockReward()}}, } for b.ID().CmpWork(cs.ChildTarget) < 0 { b.Nonce += cs.NonceFactor() @@ -265,18 +268,20 @@ func TestV2(t *testing.T) { } c, shutdown := runServer(cm, nil, wm) defer shutdown() - if err := c.AddWallet("primary", nil); err != nil { + primaryWallet, err := c.AddWallet(api.WalletUpdateRequest{Name: "primary"}) + if err != nil { t.Fatal(err) } - primary := c.Wallet("primary") - if err := primary.AddAddress(primaryAddress, nil); err != nil { + primary := c.Wallet(primaryWallet.ID) + if err := primary.AddAddress(wallet.Address{Address: primaryAddress}); err != nil { t.Fatal(err) } - if err := c.AddWallet("secondary", nil); err != nil { + secondaryWallet, err := c.AddWallet(api.WalletUpdateRequest{Name: "secondary"}) + if err != nil { t.Fatal(err) } - secondary := c.Wallet("secondary") - if err := secondary.AddAddress(secondaryAddress, nil); err != nil { + secondary := c.Wallet(secondaryWallet.ID) + if err := secondary.AddAddress(wallet.Address{Address: secondaryAddress}); err != nil { t.Fatal(err) } if err := c.Resubscribe(0); err != nil { @@ -324,12 +329,12 @@ func TestV2(t *testing.T) { key := primaryPrivateKey dest := secondaryAddress pbal, sbal := types.ZeroCurrency, types.ZeroCurrency - sces, _, err := primary.Outputs() + sces, err := primary.SiacoinOutputs(0, 100) if err != nil { t.Fatal(err) } if len(sces) == 0 { - sces, _, err = secondary.Outputs() + sces, err = secondary.SiacoinOutputs(0, 100) if err != nil { t.Fatal(err) } @@ -370,12 +375,12 @@ func TestV2(t *testing.T) { key := primaryPrivateKey dest := secondaryAddress pbal, sbal := types.ZeroCurrency, types.ZeroCurrency - sces, _, err := primary.Outputs() + sces, err := primary.SiacoinOutputs(0, 100) if err != nil { t.Fatal(err) } if len(sces) == 0 { - sces, _, err = secondary.Outputs() + sces, err = secondary.SiacoinOutputs(0, 100) if err != nil { t.Fatal(err) } @@ -487,11 +492,12 @@ func TestP2P(t *testing.T) { go s1.Run() c1, shutdown := runServer(cm1, s1, wm1) defer shutdown() - if err := c1.AddWallet("primary", nil); err != nil { + w1, err := c1.AddWallet(api.WalletUpdateRequest{Name: "primary"}) + if err != nil { t.Fatal(err) } - primary := c1.Wallet("primary") - if err := primary.AddAddress(primaryAddress, nil); err != nil { + primary := c1.Wallet(w1.ID) + if err := primary.AddAddress(wallet.Address{Address: primaryAddress}); err != nil { t.Fatal(err) } if err := c1.Resubscribe(0); err != nil { @@ -526,11 +532,13 @@ func TestP2P(t *testing.T) { go s2.Run() c2, shutdown2 := runServer(cm2, s2, wm2) defer shutdown2() - if err := c2.AddWallet("secondary", nil); err != nil { + + w2, err := c2.AddWallet(api.WalletUpdateRequest{Name: "secondary"}) + if err != nil { t.Fatal(err) } - secondary := c2.Wallet("secondary") - if err := secondary.AddAddress(secondaryAddress, nil); err != nil { + secondary := c2.Wallet(w2.ID) + if err := secondary.AddAddress(wallet.Address{Address: secondaryAddress}); err != nil { t.Fatal(err) } if err := c2.Resubscribe(0); err != nil { @@ -606,7 +614,7 @@ func TestP2P(t *testing.T) { key := primaryPrivateKey dest := secondaryAddress pbal, sbal := types.ZeroCurrency, types.ZeroCurrency - sces, _, err := primary.Outputs() + sces, err := primary.SiacoinOutputs(0, 100) if err != nil { t.Fatal(err) } @@ -614,7 +622,7 @@ func TestP2P(t *testing.T) { c = c2 key = secondaryPrivateKey dest = primaryAddress - sces, _, err = secondary.Outputs() + sces, err = secondary.SiacoinOutputs(0, 100) if err != nil { t.Fatal(err) } @@ -660,7 +668,7 @@ func TestP2P(t *testing.T) { key := primaryPrivateKey dest := secondaryAddress pbal, sbal := types.ZeroCurrency, types.ZeroCurrency - sces, _, err := primary.Outputs() + sces, err := primary.SiacoinOutputs(0, 100) if err != nil { t.Fatal(err) } @@ -668,7 +676,7 @@ func TestP2P(t *testing.T) { c = c2 key = secondaryPrivateKey dest = primaryAddress - sces, _, err = secondary.Outputs() + sces, err = secondary.SiacoinOutputs(0, 100) if err != nil { t.Fatal(err) } diff --git a/api/client.go b/api/client.go index 8729863..04d0c90 100644 --- a/api/client.go +++ b/api/client.go @@ -88,21 +88,27 @@ func (c *Client) Wallets() (ws map[string]json.RawMessage, err error) { } // AddWallet adds a wallet to the set of tracked wallets. -func (c *Client) AddWallet(name string, info json.RawMessage) (err error) { - err = c.c.PUT(fmt.Sprintf("/wallets/%v", name), info) +func (c *Client) AddWallet(uw WalletUpdateRequest) (w wallet.Wallet, err error) { + err = c.c.POST("/wallets", uw, &w) + return +} + +// UpdateWallet updates a wallet. +func (c *Client) UpdateWallet(id wallet.ID, uw WalletUpdateRequest) (w wallet.Wallet, err error) { + err = c.c.POST(fmt.Sprintf("/wallets/%v", id), uw, &w) return } // RemoveWallet deletes a wallet. If the wallet is currently subscribed, it will // be unsubscribed. -func (c *Client) RemoveWallet(name string) (err error) { - err = c.c.DELETE(fmt.Sprintf("/wallets/%v", name)) +func (c *Client) RemoveWallet(id wallet.ID) (err error) { + err = c.c.DELETE(fmt.Sprintf("/wallets/%v", id)) return } // Wallet returns a client for interacting with the specified wallet. -func (c *Client) Wallet(name string) *WalletClient { - return &WalletClient{c: c.c, name: name} +func (c *Client) Wallet(id wallet.ID) *WalletClient { + return &WalletClient{c: c.c, id: id} } // Resubscribe subscribes the wallet to consensus updates, starting at the @@ -115,57 +121,62 @@ func (c *Client) Resubscribe(height uint64) (err error) { // A WalletClient provides methods for interacting with a particular wallet on a // walletd API server. type WalletClient struct { - c jape.Client - name string + c jape.Client + id wallet.ID } // AddAddress adds the specified address and associated metadata to the // wallet. -func (c *WalletClient) AddAddress(addr types.Address, info json.RawMessage) (err error) { - err = c.c.PUT(fmt.Sprintf("/wallets/%v/addresses/%v", c.name, addr), info) +func (c *WalletClient) AddAddress(a wallet.Address) (err error) { + err = c.c.PUT(fmt.Sprintf("/wallets/%v/addresses", c.id), a) return } // RemoveAddress removes the specified address from the wallet. func (c *WalletClient) RemoveAddress(addr types.Address) (err error) { - err = c.c.DELETE(fmt.Sprintf("/wallets/%v/addresses/%v", c.name, addr)) + err = c.c.DELETE(fmt.Sprintf("/wallets/%v/addresses/%v", c.id, addr)) return } // Addresses the addresses controlled by the wallet. -func (c *WalletClient) Addresses() (resp map[types.Address]json.RawMessage, err error) { - err = c.c.GET(fmt.Sprintf("/wallets/%v/addresses", c.name), &resp) +func (c *WalletClient) Addresses() (resp []wallet.Address, err error) { + err = c.c.GET(fmt.Sprintf("/wallets/%v/addresses", c.id), &resp) return } // Balance returns the current wallet balance. func (c *WalletClient) Balance() (resp BalanceResponse, err error) { - err = c.c.GET(fmt.Sprintf("/wallets/%v/balance", c.name), &resp) + err = c.c.GET(fmt.Sprintf("/wallets/%v/balance", c.id), &resp) return } // Events returns all events relevant to the wallet. func (c *WalletClient) Events(offset, limit int) (resp []wallet.Event, err error) { - err = c.c.GET(fmt.Sprintf("/wallets/%v/events?offset=%d&limit=%d", c.name, offset, limit), &resp) + err = c.c.GET(fmt.Sprintf("/wallets/%v/events?offset=%d&limit=%d", c.id, offset, limit), &resp) return } // PoolTransactions returns all txpool transactions relevant to the wallet. func (c *WalletClient) PoolTransactions() (resp []wallet.PoolTransaction, err error) { - err = c.c.GET(fmt.Sprintf("/wallets/%v/txpool", c.name), &resp) + err = c.c.GET(fmt.Sprintf("/wallets/%v/txpool", c.id), &resp) return } -// Outputs returns the set of unspent outputs controlled by the wallet. -func (c *WalletClient) Outputs() (sc []types.SiacoinElement, sf []types.SiafundElement, err error) { - var resp WalletOutputsResponse - err = c.c.GET(fmt.Sprintf("/wallets/%v/outputs", c.name), &resp) - return resp.SiacoinOutputs, resp.SiafundOutputs, err +// SiacoinOutputs returns the set of unspent outputs controlled by the wallet. +func (c *WalletClient) SiacoinOutputs(offset, limit int) (sc []types.SiacoinElement, err error) { + err = c.c.GET(fmt.Sprintf("/wallets/%v/outputs/siacoin?offset=%d&limit=%d", c.id, offset, limit), &sc) + return +} + +// SiafundOutputs returns the set of unspent outputs controlled by the wallet. +func (c *WalletClient) SiafundOutputs(offset, limit int) (sf []types.SiafundElement, err error) { + err = c.c.GET(fmt.Sprintf("/wallets/%v/outputs/siafund?offset=%d&limit=%d", c.id, offset, limit), &sf) + return } // Reserve reserves a set outputs for use in a transaction. func (c *WalletClient) Reserve(sc []types.SiacoinOutputID, sf []types.SiafundOutputID, duration time.Duration) (err error) { - err = c.c.POST(fmt.Sprintf("/wallets/%v/reserve", c.name), WalletReserveRequest{ + err = c.c.POST(fmt.Sprintf("/wallets/%v/reserve", c.id), WalletReserveRequest{ SiacoinOutputs: sc, SiafundOutputs: sf, Duration: duration, @@ -175,7 +186,7 @@ func (c *WalletClient) Reserve(sc []types.SiacoinOutputID, sf []types.SiafundOut // Release releases a set of previously-reserved outputs. func (c *WalletClient) Release(sc []types.SiacoinOutputID, sf []types.SiafundOutputID) (err error) { - err = c.c.POST(fmt.Sprintf("/wallets/%v/release", c.name), WalletReleaseRequest{ + err = c.c.POST(fmt.Sprintf("/wallets/%v/release", c.id), WalletReleaseRequest{ SiacoinOutputs: sc, SiafundOutputs: sf, }, nil) @@ -184,7 +195,7 @@ func (c *WalletClient) Release(sc []types.SiacoinOutputID, sf []types.SiafundOut // Fund funds a siacoin transaction. func (c *WalletClient) Fund(txn types.Transaction, amount types.Currency, changeAddr types.Address) (resp WalletFundResponse, err error) { - err = c.c.POST(fmt.Sprintf("/wallets/%v/fund", c.name), WalletFundRequest{ + err = c.c.POST(fmt.Sprintf("/wallets/%v/fund", c.id), WalletFundRequest{ Transaction: txn, Amount: amount, ChangeAddress: changeAddr, @@ -194,7 +205,7 @@ func (c *WalletClient) Fund(txn types.Transaction, amount types.Currency, change // FundSF funds a siafund transaction. func (c *WalletClient) FundSF(txn types.Transaction, amount uint64, changeAddr, claimAddr types.Address) (resp WalletFundResponse, err error) { - err = c.c.POST(fmt.Sprintf("/wallets/%v/fundsf", c.name), WalletFundSFRequest{ + err = c.c.POST(fmt.Sprintf("/wallets/%v/fundsf", c.id), WalletFundSFRequest{ Transaction: txn, Amount: amount, ChangeAddress: changeAddr, diff --git a/api/server.go b/api/server.go index 81f63db..f9f33fe 100644 --- a/api/server.go +++ b/api/server.go @@ -1,7 +1,6 @@ package api import ( - "encoding/json" "errors" "net/http" "reflect" @@ -47,21 +46,21 @@ type ( WalletManager interface { Subscribe(startHeight uint64) error - AddWallet(name string, info json.RawMessage) error - DeleteWallet(name string) error - Wallets() (map[string]json.RawMessage, error) + AddWallet(wallet.Wallet) (wallet.Wallet, error) + UpdateWallet(wallet.Wallet) (wallet.Wallet, error) + DeleteWallet(wallet.ID) error + Wallets() ([]wallet.Wallet, error) - AddAddress(name string, addr types.Address, info json.RawMessage) error - RemoveAddress(name string, addr types.Address) error - Addresses(name string) (map[types.Address]json.RawMessage, error) - Events(name string, offset, limit int) ([]wallet.Event, error) - UnspentSiacoinOutputs(name string) ([]types.SiacoinElement, error) - UnspentSiafundOutputs(name string) ([]types.SiafundElement, error) - WalletBalance(walletID string) (wallet.Balance, error) - Annotate(name string, pool []types.Transaction) ([]wallet.PoolTransaction, error) + AddAddress(id wallet.ID, addr wallet.Address) error + RemoveAddress(id wallet.ID, addr types.Address) error + Addresses(id wallet.ID) ([]wallet.Address, error) + Events(id wallet.ID, offset, limit int) ([]wallet.Event, error) + UnspentSiacoinOutputs(id wallet.ID, offset, limit int) ([]types.SiacoinElement, error) + UnspentSiafundOutputs(id wallet.ID, offset, limit int) ([]types.SiafundElement, error) + WalletBalance(id wallet.ID) (wallet.Balance, error) + Annotate(id wallet.ID, pool []types.Transaction) ([]wallet.PoolTransaction, error) Reserve(ids []types.Hash256, duration time.Duration) error - AddressBalance(address types.Address) (wallet.Balance, error) } ) @@ -178,21 +177,53 @@ func (s *server) walletsHandler(jc jape.Context) { jc.Encode(wallets) } -func (s *server) walletsNameHandlerPUT(jc jape.Context) { - var name string - var info json.RawMessage - if jc.DecodeParam("name", &name) != nil || jc.Decode(&info) != nil { +func (s *server) walletsHandlerPOST(jc jape.Context) { + var req WalletUpdateRequest + w := wallet.Wallet{ + Name: req.Name, + Description: req.Description, + Metadata: req.Metadata, + } + + w, err := s.wm.AddWallet(w) + if jc.Check("couldn't add wallet", err) != nil { + return + } + jc.Encode(w) +} + +func (s *server) walletsIDHandlerPOST(jc jape.Context) { + var id wallet.ID + var req WalletUpdateRequest + if jc.DecodeParam("id", &id) != nil || jc.Decode(&req) != nil { + return + } + w := wallet.Wallet{ + ID: id, + Name: req.Name, + Description: req.Description, + Metadata: req.Metadata, + } + + w, err := s.wm.UpdateWallet(w) + if errors.Is(err, wallet.ErrNotFound) { + jc.Error(err, http.StatusNotFound) return - } else if jc.Check("couldn't add wallet", s.wm.AddWallet(name, info)) != nil { + } else if jc.Check("couldn't update wallet", err) != nil { return } + jc.Encode(w) } -func (s *server) walletsNameHandlerDELETE(jc jape.Context) { - var name string - if jc.DecodeParam("name", &name) != nil { +func (s *server) walletsIDHandlerDELETE(jc jape.Context) { + var id wallet.ID + if jc.DecodeParam("id", &id) != nil { return - } else if jc.Check("couldn't remove wallet", s.wm.DeleteWallet(name)) != nil { + } + err := s.wm.DeleteWallet(id) + if errors.Is(err, wallet.ErrNotFound) { + jc.Error(err, http.StatusNotFound) + } else if jc.Check("couldn't remove wallet", err) != nil { return } } @@ -207,32 +238,36 @@ func (s *server) resubscribeHandler(jc jape.Context) { } func (s *server) walletsAddressHandlerPUT(jc jape.Context) { - var name string - var addr types.Address - var info json.RawMessage - if jc.DecodeParam("name", &name) != nil || jc.DecodeParam("addr", &addr) != nil || jc.Decode(&info) != nil { + var id wallet.ID + var addr wallet.Address + if jc.DecodeParam("id", &id) != nil || jc.Decode(&addr) != nil { return - } else if jc.Check("couldn't add address", s.wm.AddAddress(name, addr, info)) != nil { + } else if jc.Check("couldn't add address", s.wm.AddAddress(id, addr)) != nil { return } } func (s *server) walletsAddressHandlerDELETE(jc jape.Context) { - var name string + var id wallet.ID var addr types.Address - if jc.DecodeParam("name", &name) != nil || jc.DecodeParam("addr", &addr) != nil { + if jc.DecodeParam("id", &id) != nil || jc.DecodeParam("addr", &addr) != nil { return - } else if jc.Check("couldn't remove address", s.wm.RemoveAddress(name, addr)) != nil { + } + + err := s.wm.RemoveAddress(id, addr) + if errors.Is(err, wallet.ErrNotFound) { + jc.Error(err, http.StatusNotFound) + } else if jc.Check("couldn't remove address", err) != nil { return } } func (s *server) walletsAddressesHandlerGET(jc jape.Context) { - var name string - if jc.DecodeParam("name", &name) != nil { + var id wallet.ID + if jc.DecodeParam("id", &id) != nil { return } - addrs, err := s.wm.Addresses(name) + addrs, err := s.wm.Addresses(id) if jc.Check("couldn't load addresses", err) != nil { return } @@ -240,61 +275,87 @@ func (s *server) walletsAddressesHandlerGET(jc jape.Context) { } func (s *server) walletsBalanceHandler(jc jape.Context) { - var name string - if jc.DecodeParam("name", &name) != nil { + var id wallet.ID + if jc.DecodeParam("id", &id) != nil { return } - b, err := s.wm.WalletBalance(name) - if jc.Check("couldn't load balance", err) != nil { + b, err := s.wm.WalletBalance(id) + if errors.Is(err, wallet.ErrNotFound) { + jc.Error(err, http.StatusNotFound) + return + } else if jc.Check("couldn't load balance", err) != nil { return } jc.Encode(BalanceResponse(b)) } func (s *server) walletsEventsHandler(jc jape.Context) { - var name string - offset, limit := 0, -1 - if jc.DecodeParam("name", &name) != nil || jc.DecodeForm("offset", &offset) != nil || jc.DecodeForm("limit", &limit) != nil { + var id wallet.ID + offset, limit := 0, 500 + if jc.DecodeParam("id", &id) != nil || jc.DecodeForm("offset", &offset) != nil || jc.DecodeForm("limit", &limit) != nil { return } - events, err := s.wm.Events(name, offset, limit) - if jc.Check("couldn't load events", err) != nil { + events, err := s.wm.Events(id, offset, limit) + if errors.Is(err, wallet.ErrNotFound) { + jc.Error(err, http.StatusNotFound) + return + } else if jc.Check("couldn't load events", err) != nil { return } jc.Encode(events) } func (s *server) walletsTxpoolHandler(jc jape.Context) { - var name string - if jc.DecodeParam("name", &name) != nil { + var id wallet.ID + if jc.DecodeParam("id", &id) != nil { return } - pool, err := s.wm.Annotate(name, s.cm.PoolTransactions()) - if jc.Check("couldn't annotate pool", err) != nil { + pool, err := s.wm.Annotate(id, s.cm.PoolTransactions()) + if errors.Is(err, wallet.ErrNotFound) { + jc.Error(err, http.StatusNotFound) + return + } else if jc.Check("couldn't annotate pool", err) != nil { return } jc.Encode(pool) } -func (s *server) walletsOutputsHandler(jc jape.Context) { - var name string - if jc.DecodeParam("name", &name) != nil { +func (s *server) walletsOutputsSiacoinHandler(jc jape.Context) { + var id wallet.ID + if jc.DecodeParam("id", &id) != nil { return } - scos, err := s.wm.UnspentSiacoinOutputs(name) + + offset, limit := 0, 1000 + if jc.DecodeForm("offset", &offset) != nil || jc.DecodeForm("limit", &limit) != nil { + return + } + + scos, err := s.wm.UnspentSiacoinOutputs(id, offset, limit) if jc.Check("couldn't load siacoin outputs", err) != nil { return } - sfos, err := s.wm.UnspentSiafundOutputs(name) - if jc.Check("couldn't load siafund outputs", err) != nil { + jc.Encode(scos) +} + +func (s *server) walletsOutputsSiafundHandler(jc jape.Context) { + var id wallet.ID + if jc.DecodeParam("id", &id) != nil { return } - jc.Encode(WalletOutputsResponse{ - SiacoinOutputs: scos, - SiafundOutputs: sfos, - }) + + offset, limit := 0, 1000 + if jc.DecodeForm("offset", &offset) != nil || jc.DecodeForm("limit", &limit) != nil { + return + } + + sfos, err := s.wm.UnspentSiafundOutputs(id, offset, limit) + if jc.Check("couldn't load siacoin outputs", err) != nil { + return + } + jc.Encode(sfos) } func (s *server) walletsReserveHandler(jc jape.Context) { @@ -384,12 +445,12 @@ func (s *server) walletsFundHandler(jc jape.Context) { return toSign, nil } - var name string + var id wallet.ID var wfr WalletFundRequest - if jc.DecodeParam("name", &name) != nil || jc.Decode(&wfr) != nil { + if jc.DecodeParam("id", &id) != nil || jc.Decode(&wfr) != nil { return } - utxos, err := s.wm.UnspentSiacoinOutputs(name) + utxos, err := s.wm.UnspentSiacoinOutputs(id, 0, 1000) if jc.Check("couldn't get utxos to fund transaction", err) != nil { return } @@ -458,12 +519,12 @@ func (s *server) walletsFundSFHandler(jc jape.Context) { return toSign, nil } - var name string + var id wallet.ID var wfr WalletFundSFRequest - if jc.DecodeParam("name", &name) != nil || jc.Decode(&wfr) != nil { + if jc.DecodeParam("id", &id) != nil || jc.Decode(&wfr) != nil { return } - utxos, err := s.wm.UnspentSiafundOutputs(name) + utxos, err := s.wm.UnspentSiafundOutputs(id, 0, 1000) if jc.Check("couldn't get utxos to fund transaction", err) != nil { return } @@ -503,19 +564,21 @@ func NewServer(cm ChainManager, s Syncer, wm WalletManager) http.Handler { "POST /resubscribe": srv.resubscribeHandler, - "GET /wallets": srv.walletsHandler, - "PUT /wallets/:name": srv.walletsNameHandlerPUT, - "DELETE /wallets/:name": srv.walletsNameHandlerDELETE, - "PUT /wallets/:name/addresses/:addr": srv.walletsAddressHandlerPUT, - "DELETE /wallets/:name/addresses/:addr": srv.walletsAddressHandlerDELETE, - "GET /wallets/:name/addresses": srv.walletsAddressesHandlerGET, - "GET /wallets/:name/balance": srv.walletsBalanceHandler, - "GET /wallets/:name/events": srv.walletsEventsHandler, - "GET /wallets/:name/txpool": srv.walletsTxpoolHandler, - "GET /wallets/:name/outputs": srv.walletsOutputsHandler, - "POST /wallets/:name/reserve": srv.walletsReserveHandler, - "POST /wallets/:name/release": srv.walletsReleaseHandler, - "POST /wallets/:name/fund": srv.walletsFundHandler, - "POST /wallets/:name/fundsf": srv.walletsFundSFHandler, + "GET /wallets": srv.walletsHandler, + "POST /wallets": srv.walletsHandlerPOST, + "POST /wallets/:id": srv.walletsIDHandlerPOST, + "DELETE /wallets/:id": srv.walletsIDHandlerDELETE, + "PUT /wallets/:id/addresses": srv.walletsAddressHandlerPUT, + "DELETE /wallets/:id/addresses/:addr": srv.walletsAddressHandlerDELETE, + "GET /wallets/:id/addresses": srv.walletsAddressesHandlerGET, + "GET /wallets/:id/balance": srv.walletsBalanceHandler, + "GET /wallets/:id/events": srv.walletsEventsHandler, + "GET /wallets/:id/txpool": srv.walletsTxpoolHandler, + "GET /wallets/:id/outputs/siacoin": srv.walletsOutputsSiacoinHandler, + "GET /wallets/:id/outputs/siafund": srv.walletsOutputsSiafundHandler, + "POST /wallets/:id/reserve": srv.walletsReserveHandler, + "POST /wallets/:id/release": srv.walletsReleaseHandler, + "POST /wallets/:id/fund": srv.walletsFundHandler, + "POST /wallets/:id/fundsf": srv.walletsFundSFHandler, }) } diff --git a/cmd/walletd/main.go b/cmd/walletd/main.go index 2b69192..bc19658 100644 --- a/cmd/walletd/main.go +++ b/cmd/walletd/main.go @@ -10,6 +10,7 @@ import ( "strings" "go.sia.tech/core/types" + "go.sia.tech/walletd/api" "go.sia.tech/walletd/wallet" "go.uber.org/zap" "go.uber.org/zap/zapcore" @@ -122,6 +123,9 @@ func main() { var gatewayAddr, apiAddr, dir, network, seed string var upnp, v2 bool + var minerAddrStr string + var minerBlocks int + rootCmd := flagg.Root rootCmd.Usage = flagg.SimpleUsage(rootCmd, rootUsage) rootCmd.StringVar(&gatewayAddr, "addr", ":9981", "p2p address to listen on") @@ -133,6 +137,8 @@ func main() { versionCmd := flagg.New("version", versionUsage) seedCmd := flagg.New("seed", seedUsage) mineCmd := flagg.New("mine", mineUsage) + mineCmd.IntVar(&minerBlocks, "n", -1, "mine this many blocks. If negative, mine indefinitely") + mineCmd.StringVar(&minerAddrStr, "addr", "", "address to send block rewards to (required)") balanceCmd := flagg.New("balance", balanceUsage) sendCmd := flagg.New("send", sendUsage) sendCmd.BoolVar(&v2, "v2", false, "send a v2 transaction") @@ -228,61 +234,13 @@ func main() { cmd.Usage() return } - seed := loadTestnetSeed(seed) - c := initTestnetClient(apiAddr, network, seed) - runTestnetMiner(c, seed) - case balanceCmd: - if len(cmd.Args()) != 0 { - cmd.Usage() - return - } - seed := loadTestnetSeed(seed) - c := initTestnetClient(apiAddr, network, seed) - b, err := c.Wallet("primary").Balance() - check("Couldn't get balance:", err) - out := fmt.Sprint(b.Siacoins) - if !b.ImmatureSiacoins.IsZero() { - out += fmt.Sprintf(" + %v immature", b.ImmatureSiacoins) - } - poolGained, poolLost := testnetTxpoolBalance(c, seed) - if !poolGained.IsZero() || !poolLost.IsZero() { - if poolGained.Cmp(poolLost) >= 0 { - out += fmt.Sprintf(" + %v unconfirmed", poolGained.Sub(poolLost)) - } else { - out += fmt.Sprintf(" - %v unconfirmed", poolLost.Sub(poolGained)) - } - } - fmt.Println(out) - case sendCmd: - if len(cmd.Args()) != 2 { - cmd.Usage() - return - } - seed := loadTestnetSeed(seed) - c := initTestnetClient(apiAddr, network, seed) - amount, err := types.ParseCurrency(cmd.Arg(0)) - check("Couldn't parse amount:", err) - dest, err := types.ParseAddress(cmd.Arg(1)) - check("Couldn't parse recipient address:", err) - sendTestnet(c, seed, amount, dest, v2) - - case txnsCmd: - if len(cmd.Args()) != 0 { - cmd.Usage() - return + minerAddr, err := types.ParseAddress(minerAddrStr) + if err != nil { + log.Fatal(err) } - seed := loadTestnetSeed(seed) - c := initTestnetClient(apiAddr, network, seed) - printTestnetEvents(c, seed) - case txpoolCmd: - if len(cmd.Args()) != 0 { - cmd.Usage() - return - } - seed := loadTestnetSeed(seed) - c := initTestnetClient(apiAddr, network, seed) - printTestnetTxpool(c, seed) + c := api.NewClient("http://"+apiAddr+"/api", getAPIPassword()) + runTestnetMiner(c, minerAddr, minerBlocks) } } diff --git a/cmd/walletd/testnet.go b/cmd/walletd/testnet.go index 9a66bd0..64ff63c 100644 --- a/cmd/walletd/testnet.go +++ b/cmd/walletd/testnet.go @@ -2,19 +2,14 @@ package main import ( "encoding/binary" - "encoding/hex" "fmt" "log" "math/big" - "os" - "reflect" "time" "go.sia.tech/core/consensus" "go.sia.tech/core/types" "go.sia.tech/walletd/api" - "go.sia.tech/walletd/wallet" - "golang.org/x/term" "lukechampine.com/frand" ) @@ -69,62 +64,6 @@ func TestnetAnagami() (*consensus.Network, types.Block) { return n, b } -func loadTestnetSeed(s string) wallet.Seed { - if s == "" { - fmt.Println("Seed not supplied via -seed flag, falling back to manual entry.") - fmt.Print("Seed: ") - pw, err := term.ReadPassword(int(os.Stdin.Fd())) - fmt.Println() - check("Could not read API password:", err) - if err != nil { - log.Fatal(err) - } - s = string(pw) - } - b, err := hex.DecodeString(s) - if err != nil || len(b) != 8 { - log.Fatal("Seed must be 16 hex characters") - } - var entropy [32]byte - copy(entropy[:], b) - return wallet.NewSeedFromEntropy(&entropy) -} - -func initTestnetClient(addr string, network string, seed wallet.Seed) *api.Client { - if network == "mainnet" { - log.Fatal("Testnet actions cannot be used on mainnet") - } - c := api.NewClient("http://"+addr+"/api", getAPIPassword()) - cs, err := c.ConsensusTipState() - check("Couldn't connect to API:", err) - if cs.Network.Name != network { - log.Fatalf("Testnet %q was specified, but walletd is running %v", network, cs.Network.Name) - } - ourAddr := types.StandardUnlockHash(seed.PublicKey(0)) - wc := c.Wallet("primary") - if addrs, err := wc.Addresses(); err == nil && len(addrs) > 0 { - if _, ok := addrs[ourAddr]; !ok { - log.Fatal("Wallet already initialized with a different testnet address") - } - } - if ws, _ := c.Wallets(); len(ws) == 0 { - fmt.Print("Initializing testnet wallet...") - if err := c.AddWallet("primary", nil); err != nil { - fmt.Println() - log.Fatal(err) - } else if err := wc.AddAddress(ourAddr, nil); err != nil { - fmt.Println() - log.Fatal(err) - } else if err := c.Resubscribe(0); err != nil { - fmt.Println() - log.Fatal(err) - } - fmt.Println("done.") - } - - return c -} - func mineBlock(cs consensus.State, b *types.Block) (hashes int, found bool) { buf := make([]byte, 32+8+8+32) binary.LittleEndian.PutUint64(buf[32:], b.Nonce) @@ -150,8 +89,7 @@ func mineBlock(cs consensus.State, b *types.Block) (hashes int, found bool) { return hashes, true } -func runTestnetMiner(c *api.Client, seed wallet.Seed) { - minerAddr := types.StandardUnlockHash(seed.PublicKey(0)) +func runTestnetMiner(c *api.Client, minerAddr types.Address, n int) { log.Println("Started mining into", minerAddr) start := time.Now() @@ -159,7 +97,10 @@ func runTestnetMiner(c *api.Client, seed wallet.Seed) { var blocks uint64 var last types.ChainIndex outer: - for { + for i := 0; ; i++ { + if n <= 0 && i >= n { + return + } elapsed := time.Since(start) cs, err := c.ConsensusTipState() check("Couldn't get consensus tip state:", err) @@ -216,194 +157,3 @@ outer: } } } - -func sendTestnet(c *api.Client, seed wallet.Seed, amount types.Currency, dest types.Address, v2 bool) { - ourKey := seed.PrivateKey(0) - ourUC := types.StandardUnlockConditions(seed.PublicKey(0)) - ourAddr := types.StandardUnlockHash(seed.PublicKey(0)) - - cs, err := c.ConsensusTipState() - check("Couldn't get consensus tip state:", err) - utxos, _, err := c.Wallet("primary").Outputs() - check("Couldn't get outputs:", err) - txns, v2txns, err := c.TxpoolTransactions() - if err != nil { - log.Fatal(err) - } - inPool := make(map[types.Hash256]bool) - for _, ptxn := range txns { - for _, in := range ptxn.SiacoinInputs { - inPool[types.Hash256(in.ParentID)] = true - } - } - for _, ptxn := range v2txns { - for _, in := range ptxn.SiacoinInputs { - inPool[in.Parent.ID] = true - } - } - - frand.Shuffle(len(utxos), reflect.Swapper(utxos)) - var inputSum types.Currency - rem := utxos[:0] - for _, utxo := range utxos { - if inputSum.Cmp(amount) >= 0 { - break - } else if cs.Index.Height > utxo.MaturityHeight && !inPool[utxo.ID] { - rem = append(rem, utxo) - inputSum = inputSum.Add(utxo.SiacoinOutput.Value) - } - } - utxos = rem - if inputSum.Cmp(amount) < 0 { - log.Fatal("Insufficient balance") - } - outputs := []types.SiacoinOutput{ - {Address: dest, Value: amount}, - } - minerFee := inputSum.Sub(amount) - if maxFee := types.Siacoins(1); minerFee.Cmp(maxFee) > 0 { - minerFee = maxFee - } - if change := inputSum.Sub(amount.Add(minerFee)); !change.IsZero() { - outputs = append(outputs, types.SiacoinOutput{ - Address: ourAddr, - Value: change, - }) - } - - if v2 { - txn := types.V2Transaction{ - SiacoinInputs: make([]types.V2SiacoinInput, len(utxos)), - SiacoinOutputs: outputs, - MinerFee: minerFee, - } - for i, sce := range utxos { - txn.SiacoinInputs[i].Parent = sce - txn.SiacoinInputs[i].SatisfiedPolicy.Policy = types.SpendPolicy{ - Type: types.PolicyTypeUnlockConditions(ourUC), - } - } - sigHash := cs.InputSigHash(txn) - for i := range utxos { - txn.SiacoinInputs[i].SatisfiedPolicy.Signatures = []types.Signature{ourKey.SignHash(sigHash)} - } - if err := c.TxpoolBroadcast(nil, []types.V2Transaction{txn}); err != nil { - log.Fatal(err) - } - log.Println("Broadcast", txn.ID(), "successfully") - } else { - txn := types.Transaction{ - SiacoinInputs: make([]types.SiacoinInput, len(utxos)), - SiacoinOutputs: outputs, - Signatures: make([]types.TransactionSignature, len(utxos)), - } - if !minerFee.IsZero() { - txn.MinerFees = append(txn.MinerFees, minerFee) - } - for i, sce := range utxos { - txn.SiacoinInputs[i] = types.SiacoinInput{ - ParentID: types.SiacoinOutputID(sce.ID), - UnlockConditions: ourUC, - } - } - cs, _ := c.ConsensusTipState() - for i, sce := range utxos { - txn.Signatures[i] = wallet.StandardTransactionSignature(sce.ID) - wallet.SignTransaction(cs, &txn, i, ourKey) - } - if err := c.TxpoolBroadcast([]types.Transaction{txn}, nil); err != nil { - log.Fatal(err) - } - log.Println("Broadcast", txn.ID(), "successfully") - } -} - -func printTestnetEvents(c *api.Client, seed wallet.Seed) { - ourAddr := types.StandardUnlockHash(seed.PublicKey(0)) - events, err := c.Wallet("primary").Events(0, -1) - check("Couldn't get events:", err) - for i := range events { - e := events[len(events)-1-i] - switch t := e.Data.(type) { - case *wallet.EventTransaction: - if len(t.SiacoinInputs) == 0 || len(t.SiacoinOutputs) == 0 { - continue - } - sci := t.SiacoinInputs[0].SiacoinOutput - sco := t.SiacoinOutputs[0].SiacoinOutput - if sci.Address == ourAddr { - fmt.Printf("%14v (%v): Sent %v (+ %v fee) to %v\n", e.Index, e.Timestamp.Format("Jan _2 @ 15:04:05"), sco.Value, t.Fee, sco.Address) - } else { - fmt.Printf("%14v (%v): Received %v from %v\n", e.Index, e.Timestamp.Format("Jan _2 @ 15:04:05"), sco.Value, sci.Address) - } - case *wallet.EventMinerPayout: - sco := t.SiacoinOutput.SiacoinOutput - fmt.Printf("%14v (%v): Earned %v miner payout from block %v\n", e.Index, e.Timestamp.Format("Jan _2 @ 15:04:05"), sco.Value, e.Index) - } - } -} - -func testnetTxpoolBalance(c *api.Client, seed wallet.Seed) (gained, lost types.Currency) { - ourAddr := types.StandardUnlockHash(seed.PublicKey(0)) - txns, v2txns, err := c.TxpoolTransactions() - check("Couldn't get txpool transactions:", err) - for _, txn := range txns { - if len(txn.SiacoinInputs) == 0 || len(txn.SiacoinOutputs) == 0 { - continue - } - sco := txn.SiacoinOutputs[0] - if txn.SiacoinInputs[0].UnlockConditions.UnlockHash() == ourAddr { - lost = lost.Add(sco.Value).Add(txn.TotalFees()) - } else if sco.Address == ourAddr { - gained = gained.Add(sco.Value) - } - } - for _, txn := range v2txns { - if len(txn.SiacoinInputs) == 0 || len(txn.SiacoinOutputs) == 0 { - continue - } - sco := txn.SiacoinOutputs[0] - if txn.SiacoinInputs[0].Parent.SiacoinOutput.Address == ourAddr { - lost = lost.Add(sco.Value).Add(txn.MinerFee) - } else if sco.Address == ourAddr { - gained = gained.Add(sco.Value) - } - } - return -} - -func printTestnetTxpool(c *api.Client, seed wallet.Seed) { - ourAddr := types.StandardUnlockHash(seed.PublicKey(0)) - txns, v2txns, err := c.TxpoolTransactions() - check("Couldn't get txpool transactions:", err) - if len(txns) == 0 && len(v2txns) == 0 { - fmt.Println("No transactions in txpool.") - return - } - for _, txn := range txns { - if len(txn.SiacoinInputs) == 0 || len(txn.SiacoinOutputs) == 0 { - continue - } - id := txn.ID() - sci := txn.SiacoinInputs[0] - sco := txn.SiacoinOutputs[0] - if sci.UnlockConditions.UnlockHash() == ourAddr { - fmt.Printf("%x (v1): Sending %v (+ %v fee) to %v\n", id[:4], sco.Value, txn.TotalFees(), sco.Address) - } else if sco.Address == ourAddr { - fmt.Printf("%x (v1): Receiving %v from %v\n", id[:4], sco.Value, sci.UnlockConditions.UnlockHash()) - } - } - for _, txn := range v2txns { - if len(txn.SiacoinInputs) == 0 || len(txn.SiacoinOutputs) == 0 { - continue - } - id := txn.ID() - sci := txn.SiacoinInputs[0].Parent.SiacoinOutput - sco := txn.SiacoinOutputs[0] - if sci.Address == ourAddr { - fmt.Printf("%x (v2): Sending %v (+ %v fee) to %v\n", id[:4], sco.Value, txn.MinerFee, sco.Address) - } else if sco.Address == ourAddr { - fmt.Printf("%x (v2): Receiving %v from %v\n", id[:4], sco.Value, sci.Address) - } - } -} diff --git a/go.mod b/go.mod index 6aa530d..de2866e 100644 --- a/go.mod +++ b/go.mod @@ -6,8 +6,8 @@ require ( github.com/mattn/go-sqlite3 v1.14.21 go.sia.tech/core v0.2.1 go.sia.tech/coreutils v0.0.0-20240130201319-8303550528d7 - go.sia.tech/jape v0.9.0 - go.sia.tech/web/walletd v0.17.0 + go.sia.tech/jape v0.11.2-0.20240124024603-93559895d640 + go.sia.tech/web/walletd v0.16.0 go.uber.org/zap v1.26.0 golang.org/x/term v0.6.0 lukechampine.com/flagg v1.1.1 diff --git a/go.sum b/go.sum index 4ac7bb1..6bff896 100644 --- a/go.sum +++ b/go.sum @@ -16,14 +16,14 @@ go.sia.tech/core v0.2.1 h1:CqmMd+T5rAhC+Py3NxfvGtvsj/GgwIqQHHVrdts/LqY= go.sia.tech/core v0.2.1/go.mod h1:3EoY+rR78w1/uGoXXVqcYdwSjSJKuEMI5bL7WROA27Q= go.sia.tech/coreutils v0.0.0-20240130201319-8303550528d7 h1:G2l6fRzAdNZy2z7+FhoG2y8ARtFpR6PkXXTB5tkdfZ8= go.sia.tech/coreutils v0.0.0-20240130201319-8303550528d7/go.mod h1:3Mb206QDd3NtRiaHZ2kN87/HKXhcBF6lHVatS7PkViY= -go.sia.tech/jape v0.9.0 h1:kWgMFqALYhLMJYOwWBgJda5ko/fi4iZzRxHRP7pp8NY= -go.sia.tech/jape v0.9.0/go.mod h1:4QqmBB+t3W7cNplXPj++ZqpoUb2PeiS66RLpXmEGap4= +go.sia.tech/jape v0.11.2-0.20240124024603-93559895d640 h1:mSaJ622P7T/M97dAK8iPV+IRIC9M5vV28NHeceoWO3M= +go.sia.tech/jape v0.11.2-0.20240124024603-93559895d640/go.mod h1:4QqmBB+t3W7cNplXPj++ZqpoUb2PeiS66RLpXmEGap4= go.sia.tech/mux v1.2.0 h1:ofa1Us9mdymBbGMY2XH/lSpY8itFsKIo/Aq8zwe+GHU= go.sia.tech/mux v1.2.0/go.mod h1:Yyo6wZelOYTyvrHmJZ6aQfRoer3o4xyKQ4NmQLJrBSo= go.sia.tech/web v0.0.0-20230628194305-c6e1696bad89 h1:wB/JRFeTEs6gviB6k7QARY7Goh54ufkADsdBdn0ZhRo= go.sia.tech/web v0.0.0-20230628194305-c6e1696bad89/go.mod h1:RKODSdOmR3VtObPAcGwQqm4qnqntDVFylbvOBbWYYBU= -go.sia.tech/web/walletd v0.17.0 h1:8k/m1L50LIylw1HYLlTuc3e4bYlx//qZ8xG4C/YNeA0= -go.sia.tech/web/walletd v0.17.0/go.mod h1:OHFWEbjLCR5I06E05GA98HIAdacTM5Ag7sL9ubcvgKw= +go.sia.tech/web/walletd v0.16.0 h1:tCERgjsz4orokM94kt7PH2tNweHdOwK5aoPsCXes5HM= +go.sia.tech/web/walletd v0.16.0/go.mod h1:OHFWEbjLCR5I06E05GA98HIAdacTM5Ag7sL9ubcvgKw= go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk= go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo= go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= diff --git a/persist/sqlite/addresses.go b/persist/sqlite/addresses.go new file mode 100644 index 0000000..daf4cd0 --- /dev/null +++ b/persist/sqlite/addresses.go @@ -0,0 +1,48 @@ +package sqlite + +import ( + "fmt" + + "go.sia.tech/core/types" + "go.sia.tech/walletd/wallet" +) + +// AddressBalance returns the balance of a single address. +func (s *Store) AddressBalance(address types.Address) (balance wallet.Balance, err error) { + err = s.transaction(func(tx *txn) error { + const query = `SELECT siacoin_balance, immature_siacoin_balance, siafund_balance FROM sia_addresses WHERE sia_address=$1` + return tx.QueryRow(query, encode(address)).Scan(decode(&balance.Siacoins), decode(&balance.ImmatureSiacoins), &balance.Siafunds) + }) + return +} + +// AddressEvents returns the events of a single address. +func (s *Store) AddressEvents(address types.Address, limit, offset int) (events []wallet.Event, err error) { + err = s.transaction(func(tx *txn) error { + const query = `SELECT ev.id, ev.event_id, ev.maturity_height, ev.date_created, ci.height, ci.block_id, ev.event_type, ev.event_data + FROM events ev + INNER JOIN chain_indices ci ON (ev.index_id = ci.id) + INNER JOIN event_addresses ea ON (ev.id = ea.event_id) + INNER JOIN sia_addresses sa ON (ea.address_id = sa.id) + WHERE sa.sia_address = $1 + ORDER BY ev.maturity_height DESC, ev.id DESC + LIMIT $2 OFFSET $3` + + rows, err := tx.Query(query, encode(address), limit, offset) + if err != nil { + return err + } + defer rows.Close() + + for rows.Next() { + event, _, err := scanEvent(rows) + if err != nil { + return fmt.Errorf("failed to scan event: %w", err) + } + + events = append(events, event) + } + return rows.Err() + }) + return +} diff --git a/persist/sqlite/consensus.go b/persist/sqlite/consensus.go index 0f3e67b..c110087 100644 --- a/persist/sqlite/consensus.go +++ b/persist/sqlite/consensus.go @@ -45,7 +45,7 @@ func (ut *updateTx) SiacoinStateElements() ([]types.StateElement, error) { } elements = append(elements, se) } - return elements, nil + return elements, rows.Err() } func (ut *updateTx) UpdateSiacoinStateElements(elements []types.StateElement) error { @@ -82,7 +82,7 @@ func (ut *updateTx) SiafundStateElements() ([]types.StateElement, error) { } elements = append(elements, se) } - return elements, nil + return elements, rows.Err() } func (ut *updateTx) UpdateSiafundStateElements(elements []types.StateElement) error { @@ -160,6 +160,9 @@ WHERE maturity_height=$1` } elements = append(elements, element) } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("failed to scan siacoin elements: %w", err) + } return } diff --git a/persist/sqlite/consensus_test.go b/persist/sqlite/consensus_test.go index 1d064ba..8462428 100644 --- a/persist/sqlite/consensus_test.go +++ b/persist/sqlite/consensus_test.go @@ -106,9 +106,10 @@ func TestReorg(t *testing.T) { pk := types.GeneratePrivateKey() addr := types.StandardUnlockHash(pk.PublicKey()) - if err := db.AddWallet("test", nil); err != nil { + w, err := db.AddWallet(wallet.Wallet{Name: "test"}) + if err != nil { t.Fatal(err) - } else if err := db.AddAddress("test", addr, nil); err != nil { + } else if err := db.AddWalletAddress(w.ID, wallet.Address{Address: addr}); err != nil { t.Fatal(err) } @@ -128,7 +129,7 @@ func TestReorg(t *testing.T) { } // check that a payout event was recorded - events, err := db.WalletEvents("test", 0, 100) + events, err := db.WalletEvents(w.ID, 0, 100) if err != nil { t.Fatal(err) } else if len(events) != 1 { @@ -138,7 +139,7 @@ func TestReorg(t *testing.T) { } // check that the utxo was created - utxos, err := db.UnspentSiacoinOutputs("test") + utxos, err := db.WalletSiacoinOutputs(w.ID, 0, 100) if err != nil { t.Fatal(err) } else if len(utxos) != 1 { @@ -170,7 +171,7 @@ func TestReorg(t *testing.T) { } // check that the payout event was reverted - events, err = db.WalletEvents("test", 0, 100) + events, err = db.WalletEvents(w.ID, 0, 100) if err != nil { t.Fatal(err) } else if len(events) != 0 { @@ -178,7 +179,7 @@ func TestReorg(t *testing.T) { } // check that the utxo was removed - utxos, err = db.UnspentSiacoinOutputs("test") + utxos, err = db.WalletSiacoinOutputs(w.ID, 0, 100) if err != nil { t.Fatal(err) } else if len(utxos) != 0 { @@ -201,7 +202,7 @@ func TestReorg(t *testing.T) { } // check that a payout event was recorded - events, err = db.WalletEvents("test", 0, 100) + events, err = db.WalletEvents(w.ID, 0, 100) if err != nil { t.Fatal(err) } else if len(events) != 1 { @@ -211,7 +212,7 @@ func TestReorg(t *testing.T) { } // check that the utxo was created - utxos, err = db.UnspentSiacoinOutputs("test") + utxos, err = db.WalletSiacoinOutputs(w.ID, 0, 100) if err != nil { t.Fatal(err) } else if len(utxos) != 1 { @@ -266,7 +267,7 @@ func TestReorg(t *testing.T) { } // check that only the single utxo still exists - utxos, err = db.UnspentSiacoinOutputs("test") + utxos, err = db.WalletSiacoinOutputs(w.ID, 0, 100) if err != nil { t.Fatal(err) } else if len(utxos) != 1 { @@ -310,9 +311,10 @@ func TestEphemeralBalance(t *testing.T) { pk := types.GeneratePrivateKey() addr := types.StandardUnlockHash(pk.PublicKey()) - if err := db.AddWallet("test", nil); err != nil { + w, err := db.AddWallet(wallet.Wallet{Name: "test"}) + if err != nil { t.Fatal(err) - } else if err := db.AddAddress("test", addr, nil); err != nil { + } else if err := db.AddWalletAddress(w.ID, wallet.Address{Address: addr}); err != nil { t.Fatal(err) } @@ -334,7 +336,7 @@ func TestEphemeralBalance(t *testing.T) { } // check that a payout event was recorded - events, err := db.WalletEvents("test", 0, 100) + events, err := db.WalletEvents(w.ID, 0, 100) if err != nil { t.Fatal(err) } else if len(events) != 1 { @@ -353,7 +355,7 @@ func TestEphemeralBalance(t *testing.T) { } // create a transaction that spends the matured payout - utxos, err := db.UnspentSiacoinOutputs("test") + utxos, err := db.WalletSiacoinOutputs(w.ID, 0, 100) if err != nil { t.Fatal(err) } else if len(utxos) != 1 { @@ -424,7 +426,7 @@ func TestEphemeralBalance(t *testing.T) { } // check that both transactions were added - events, err = db.WalletEvents("test", 0, 100) + events, err = db.WalletEvents(w.ID, 0, 100) if err != nil { t.Fatal(err) } else if len(events) != 3 { // 1 payout, 2 transactions @@ -462,7 +464,7 @@ func TestEphemeralBalance(t *testing.T) { } // check that only the payout event remains - events, err = db.WalletEvents("test", 0, 100) + events, err = db.WalletEvents(w.ID, 0, 100) if err != nil { t.Fatal(err) } else if len(events) != 1 { @@ -504,9 +506,10 @@ func TestV2(t *testing.T) { pk := types.GeneratePrivateKey() addr := types.StandardUnlockHash(pk.PublicKey()) - if err := db.AddWallet("test", nil); err != nil { + w, err := db.AddWallet(wallet.Wallet{Name: "test"}) + if err != nil { t.Fatal(err) - } else if err := db.AddAddress("test", addr, nil); err != nil { + } else if err := db.AddWalletAddress(w.ID, wallet.Address{Address: addr}); err != nil { t.Fatal(err) } @@ -525,7 +528,7 @@ func TestV2(t *testing.T) { } // check that a payout event was recorded - events, err := db.WalletEvents("test", 0, 100) + events, err := db.WalletEvents(w.ID, 0, 100) if err != nil { t.Fatal(err) } else if len(events) != 1 { @@ -543,7 +546,7 @@ func TestV2(t *testing.T) { } // create a v2 transaction that spends the matured payout - utxos, err := db.UnspentSiacoinOutputs("test") + utxos, err := db.WalletSiacoinOutputs(w.ID, 0, 100) if err != nil { t.Fatal(err) } @@ -577,7 +580,7 @@ func TestV2(t *testing.T) { } // check that a transaction event was recorded - events, err = db.WalletEvents("test", 0, 100) + events, err = db.WalletEvents(w.ID, 0, 100) if err != nil { t.Fatal(err) } else if len(events) != 2 { diff --git a/persist/sqlite/init.sql b/persist/sqlite/init.sql index b7c1767..7d12cc0 100644 --- a/persist/sqlite/init.sql +++ b/persist/sqlite/init.sql @@ -34,16 +34,23 @@ CREATE TABLE siafund_elements ( CREATE INDEX siafund_elements_address_id ON siafund_elements (address_id); CREATE TABLE wallets ( - id TEXT PRIMARY KEY NOT NULL, - extra_data BLOB NOT NULL + id INTEGER PRIMARY KEY, + friendly_name TEXT NOT NULL, + description TEXT NOT NULL, + date_created INTEGER NOT NULL, + last_updated INTEGER NOT NULL, + extra_data BLOB ); CREATE TABLE wallet_addresses ( - wallet_id TEXT NOT NULL REFERENCES wallets (id), + wallet_id INTEGER NOT NULL REFERENCES wallets (id), address_id INTEGER NOT NULL REFERENCES sia_addresses (id), - extra_data BLOB NOT NULL, + description TEXT NOT NULL, + spend_policy BLOB, + extra_data BLOB, UNIQUE (wallet_id, address_id) ); +CREATE INDEX wallet_addresses_wallet_id ON wallet_addresses (wallet_id); CREATE INDEX wallet_addresses_address_id ON wallet_addresses (address_id); CREATE TABLE events ( @@ -53,9 +60,10 @@ CREATE TABLE events ( maturity_height INTEGER NOT NULL, date_created INTEGER NOT NULL, event_type TEXT NOT NULL, - event_data TEXT NOT NULL + event_data BLOB NOT NULL ); + CREATE TABLE event_addresses ( event_id INTEGER NOT NULL REFERENCES events (id) ON DELETE CASCADE, address_id INTEGER NOT NULL REFERENCES sia_addresses (id), diff --git a/persist/sqlite/peers.go b/persist/sqlite/peers.go index 4d8de8d..1427737 100644 --- a/persist/sqlite/peers.go +++ b/persist/sqlite/peers.go @@ -54,7 +54,7 @@ func (s *Store) Peers() (peers []string) { } peers = append(peers, peer) } - return nil + return rows.Err() }) if err != nil { panic(err) // 😔 diff --git a/persist/sqlite/wallet.go b/persist/sqlite/wallet.go index b2136e9..7f76eda 100644 --- a/persist/sqlite/wallet.go +++ b/persist/sqlite/wallet.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "time" "go.sia.tech/core/types" "go.sia.tech/walletd/wallet" @@ -19,7 +20,47 @@ RETURNING id` return } -func getWalletEvents(tx *txn, walletID string, offset, limit int) (events []wallet.Event, eventIDs []int64, err error) { +func scanEvent(s scanner) (ev wallet.Event, eventID int64, err error) { + var eventType string + var eventBuf []byte + + err = s.Scan(&eventID, decode(&ev.ID), &ev.MaturityHeight, decode(&ev.Timestamp), &ev.Index.Height, decode(&ev.Index.ID), &eventType, &eventBuf) + if err != nil { + return + } + + switch eventType { + case wallet.EventTypeTransaction: + var tx wallet.EventTransaction + if err = json.Unmarshal(eventBuf, &tx); err != nil { + return wallet.Event{}, 0, fmt.Errorf("failed to unmarshal transaction event: %w", err) + } + ev.Data = &tx + case wallet.EventTypeContractPayout: + var m wallet.EventContractPayout + if err = json.Unmarshal(eventBuf, &m); err != nil { + return wallet.Event{}, 0, fmt.Errorf("failed to unmarshal missed file contract event: %w", err) + } + ev.Data = &m + case wallet.EventTypeMinerPayout: + var m wallet.EventMinerPayout + if err = json.Unmarshal(eventBuf, &m); err != nil { + return wallet.Event{}, 0, fmt.Errorf("failed to unmarshal payout event: %w", err) + } + ev.Data = &m + case wallet.EventTypeFoundationSubsidy: + var m wallet.EventFoundationSubsidy + if err = json.Unmarshal(eventBuf, &m); err != nil { + return wallet.Event{}, 0, fmt.Errorf("failed to unmarshal foundation subsidy event: %w", err) + } + ev.Data = &m + default: + return wallet.Event{}, 0, fmt.Errorf("unknown event type: %s", eventType) + } + return +} + +func getWalletEvents(tx *txn, id wallet.ID, offset, limit int) (events []wallet.Event, eventIDs []int64, err error) { const query = `SELECT ev.id, ev.event_id, ev.maturity_height, ev.date_created, ci.height, ci.block_id, ev.event_type, ev.event_data FROM events ev INNER JOIN chain_indices ci ON (ev.index_id = ci.id) @@ -27,65 +68,34 @@ func getWalletEvents(tx *txn, walletID string, offset, limit int) (events []wall ORDER BY ev.maturity_height DESC, ev.id DESC LIMIT $2 OFFSET $3` - rows, err := tx.Query(query, walletID, limit, offset) + rows, err := tx.Query(query, id, limit, offset) if err != nil { return nil, nil, err } defer rows.Close() for rows.Next() { - var eventID int64 - var event wallet.Event - var eventType string - var eventBuf []byte - - err := rows.Scan(&eventID, decode(&event.ID), &event.MaturityHeight, decode(&event.Timestamp), &event.Index.Height, decode(&event.Index.ID), &eventType, &eventBuf) + event, eventID, err := scanEvent(rows) if err != nil { return nil, nil, fmt.Errorf("failed to scan event: %w", err) } - switch eventType { - case wallet.EventTypeTransaction: - var tx wallet.EventTransaction - if err = json.Unmarshal(eventBuf, &tx); err != nil { - return nil, nil, fmt.Errorf("failed to unmarshal transaction event: %w", err) - } - event.Data = &tx - case wallet.EventTypeContractPayout: - var m wallet.EventContractPayout - if err = json.Unmarshal(eventBuf, &m); err != nil { - return nil, nil, fmt.Errorf("failed to unmarshal missed file contract event: %w", err) - } - event.Data = &m - case wallet.EventTypeMinerPayout: - var m wallet.EventMinerPayout - if err = json.Unmarshal(eventBuf, &m); err != nil { - return nil, nil, fmt.Errorf("failed to unmarshal payout event: %w", err) - } - event.Data = &m - case wallet.EventTypeFoundationSubsidy: - var m wallet.EventFoundationSubsidy - if err = json.Unmarshal(eventBuf, &m); err != nil { - return nil, nil, fmt.Errorf("failed to unmarshal foundation subsidy event: %w", err) - } - event.Data = &m - default: - return nil, nil, fmt.Errorf("unknown event type: %s", eventType) - } - events = append(events, event) eventIDs = append(eventIDs, eventID) } + if err = rows.Err(); err != nil { + return nil, nil, err + } return } -func (s *Store) getWalletEventRelevantAddresses(tx *txn, walletID string, eventIDs []int64) (map[int64][]types.Address, error) { +func (s *Store) getWalletEventRelevantAddresses(tx *txn, id wallet.ID, eventIDs []int64) (map[int64][]types.Address, error) { query := `SELECT ea.event_id, sa.sia_address FROM event_addresses ea INNER JOIN sia_addresses sa ON (ea.address_id = sa.id) WHERE event_id IN (` + queryPlaceHolders(len(eventIDs)) + `) AND address_id IN (SELECT address_id FROM wallet_addresses WHERE wallet_id=?)` - rows, err := tx.Query(query, append(queryArgs(eventIDs), walletID)...) + rows, err := tx.Query(query, append(queryArgs(eventIDs), id)...) if err != nil { return nil, err } @@ -100,19 +110,19 @@ WHERE event_id IN (` + queryPlaceHolders(len(eventIDs)) + `) AND address_id IN ( } relevantAddresses[eventID] = append(relevantAddresses[eventID], address) } - return relevantAddresses, nil + return relevantAddresses, rows.Err() } // WalletEvents returns the events relevant to a wallet, sorted by height descending. -func (s *Store) WalletEvents(walletID string, offset, limit int) (events []wallet.Event, err error) { +func (s *Store) WalletEvents(id wallet.ID, offset, limit int) (events []wallet.Event, err error) { err = s.transaction(func(tx *txn) error { var dbIDs []int64 - events, dbIDs, err = getWalletEvents(tx, walletID, offset, limit) + events, dbIDs, err = getWalletEvents(tx, id, offset, limit) if err != nil { return fmt.Errorf("failed to get wallet events: %w", err) } - eventRelevantAddresses, err := s.getWalletEventRelevantAddresses(tx, walletID, dbIDs) + eventRelevantAddresses, err := s.getWalletEventRelevantAddresses(tx, id, dbIDs) if err != nil { return fmt.Errorf("failed to get relevant addresses: %w", err) } @@ -126,35 +136,49 @@ func (s *Store) WalletEvents(walletID string, offset, limit int) (events []walle } // AddWallet adds a wallet to the database. -func (s *Store) AddWallet(name string, info json.RawMessage) error { - if info == nil { - info = json.RawMessage("{}") - } - return s.transaction(func(tx *txn) error { - const query = `INSERT INTO wallets (id, extra_data) VALUES ($1, $2)` +func (s *Store) AddWallet(w wallet.Wallet) (wallet.Wallet, error) { + w.DateCreated = time.Now() + w.LastUpdated = time.Now() - _, err := tx.Exec(query, name, info) - if err != nil { - return fmt.Errorf("failed to insert wallet: %w", err) + err := s.transaction(func(tx *txn) error { + const query = `INSERT INTO wallets (friendly_name, description, date_created, last_updated, extra_data) VALUES ($1, $2, $3, $4, $5) RETURNING id` + return tx.QueryRow(query, w.Name, w.Description, encode(w.DateCreated), encode(w.LastUpdated), w.Metadata).Scan(&w.ID) + }) + return w, err +} + +// UpdateWallet updates a wallet in the database. +func (s *Store) UpdateWallet(w wallet.Wallet) (wallet.Wallet, error) { + w.LastUpdated = time.Now() + err := s.transaction(func(tx *txn) error { + var dummyID int64 + const query = `UPDATE wallets SET friendly_name=$1, description=$2, last_updated=$3, extra_data=$4 WHERE id=$5 RETURNING id, date_created, last_updated` + err := tx.QueryRow(query, w.Name, w.Description, encode(w.LastUpdated), w.Metadata, w.ID).Scan(&dummyID, decode(&w.DateCreated), decode(&w.LastUpdated)) + if errors.Is(err, sql.ErrNoRows) { + return wallet.ErrNotFound } - return nil + return err }) + return w, err } // DeleteWallet deletes a wallet from the database. This does not stop tracking // addresses that were previously associated with the wallet. -func (s *Store) DeleteWallet(name string) error { +func (s *Store) DeleteWallet(id wallet.ID) error { return s.transaction(func(tx *txn) error { - _, err := tx.Exec(`DELETE FROM wallets WHERE id=$1`, name) + var dummyID int64 + err := tx.QueryRow(`DELETE FROM wallets WHERE id=$1 RETURNING id`, id).Scan(&dummyID) + if errors.Is(err, sql.ErrNoRows) { + return wallet.ErrNotFound + } return err }) } // Wallets returns a map of wallet names to wallet extra data. -func (s *Store) Wallets() (map[string]json.RawMessage, error) { - wallets := make(map[string]json.RawMessage) - err := s.transaction(func(tx *txn) error { - const query = `SELECT id, extra_data FROM wallets` +func (s *Store) Wallets() (wallets []wallet.Wallet, err error) { + err = s.transaction(func(tx *txn) error { + const query = `SELECT id, friendly_name, description, date_created, last_updated, extra_data FROM wallets` rows, err := tx.Query(query) if err != nil { @@ -163,80 +187,113 @@ func (s *Store) Wallets() (map[string]json.RawMessage, error) { defer rows.Close() for rows.Next() { - var friendlyName string - var extraData json.RawMessage - if err := rows.Scan(&friendlyName, &extraData); err != nil { + var w wallet.Wallet + if err := rows.Scan(&w.ID, &w.Name, &w.Description, decode(&w.DateCreated), decode(&w.LastUpdated), (*[]byte)(&w.Metadata)); err != nil { return fmt.Errorf("failed to scan wallet: %w", err) } - wallets[friendlyName] = extraData + wallets = append(wallets, w) } - return nil + return rows.Err() }) - return wallets, err + return } -// AddAddress adds an address to a wallet. -func (s *Store) AddAddress(walletID string, address types.Address, info json.RawMessage) error { - if info == nil { - info = json.RawMessage("{}") - } +// AddWalletAddress adds an address to a wallet. +func (s *Store) AddWalletAddress(id wallet.ID, addr wallet.Address) error { return s.transaction(func(tx *txn) error { - addressID, err := insertAddress(tx, address) + if err := walletExists(tx, id); err != nil { + return err + } + + addressID, err := insertAddress(tx, addr.Address) if err != nil { return fmt.Errorf("failed to insert address: %w", err) } - _, err = tx.Exec(`INSERT INTO wallet_addresses (wallet_id, extra_data, address_id) VALUES ($1, $2, $3)`, walletID, info, addressID) + + var encodedPolicy any + if addr.SpendPolicy != nil { + encodedPolicy = encode(*addr.SpendPolicy) + } + + _, err = tx.Exec(`INSERT INTO wallet_addresses (wallet_id, address_id, description, spend_policy, extra_data) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (wallet_id, address_id) DO UPDATE set description=EXCLUDED.description, spend_policy=EXCLUDED.spend_policy, extra_data=EXCLUDED.extra_data`, id, addressID, addr.Description, encodedPolicy, addr.Metadata) return err }) } -// RemoveAddress removes an address from a wallet. This does not stop tracking +// RemoveWalletAddress removes an address from a wallet. This does not stop tracking // the address. -func (s *Store) RemoveAddress(walletID string, address types.Address) error { +func (s *Store) RemoveWalletAddress(id wallet.ID, address types.Address) error { return s.transaction(func(tx *txn) error { - const query = `DELETE FROM wallet_addresses WHERE wallet_id=$1 AND address_id=(SELECT id FROM sia_addresses WHERE sia_address=$2)` - _, err := tx.Exec(query, walletID, encode(address)) + const query = `DELETE FROM wallet_addresses WHERE wallet_id=$1 AND address_id=(SELECT id FROM sia_addresses WHERE sia_address=$2) RETURNING address_id` + var dummyID int64 + err := tx.QueryRow(query, id, encode(address)).Scan(&dummyID) + if errors.Is(err, sql.ErrNoRows) { + return wallet.ErrNotFound + } return err }) } -// Addresses returns a map of addresses to their extra data for a wallet. -func (s *Store) Addresses(walletID string) (map[types.Address]json.RawMessage, error) { - addresses := make(map[types.Address]json.RawMessage) - err := s.transaction(func(tx *txn) error { - const query = `SELECT sa.sia_address, wa.extra_data +// WalletAddresses returns a slice of addresses registered to the wallet. +func (s *Store) WalletAddresses(id wallet.ID) (addresses []wallet.Address, err error) { + err = s.transaction(func(tx *txn) error { + if err := walletExists(tx, id); err != nil { + return err + } + + const query = `SELECT sa.sia_address, wa.description, wa.spend_policy, wa.extra_data FROM wallet_addresses wa INNER JOIN sia_addresses sa ON (sa.id = wa.address_id) WHERE wa.wallet_id=$1` - rows, err := tx.Query(query, walletID) + rows, err := tx.Query(query, id) if err != nil { return err } defer rows.Close() for rows.Next() { - var address types.Address - var extraData json.RawMessage - if err := rows.Scan(decode(&address), &extraData); err != nil { + var address wallet.Address + var decodedPolicy any + if err := rows.Scan(decode(&address.Address), &address.Description, &decodedPolicy, (*[]byte)(&address.Metadata)); err != nil { return fmt.Errorf("failed to scan address: %w", err) } - addresses[address] = extraData + + if decodedPolicy != nil { + switch v := decodedPolicy.(type) { + case []byte: + dec := types.NewBufDecoder(v) + address.SpendPolicy = new(types.SpendPolicy) + address.SpendPolicy.DecodeFrom(dec) + if err := dec.Err(); err != nil { + return fmt.Errorf("failed to decode spend policy: %w", err) + } + default: + return fmt.Errorf("unexpected spend policy type: %T", decodedPolicy) + } + } + + addresses = append(addresses, address) } - return nil + return rows.Err() }) - return addresses, err + return } -// UnspentSiacoinOutputs returns the unspent siacoin outputs for a wallet. -func (s *Store) UnspentSiacoinOutputs(walletID string) (siacoins []types.SiacoinElement, err error) { +// WalletSiacoinOutputs returns the unspent siacoin outputs for a wallet. +func (s *Store) WalletSiacoinOutputs(id wallet.ID, offset, limit int) (siacoins []types.SiacoinElement, err error) { err = s.transaction(func(tx *txn) error { + if err := walletExists(tx, id); err != nil { + return err + } + const query = `SELECT se.id, se.leaf_index, se.merkle_proof, se.siacoin_value, sa.sia_address, se.maturity_height FROM siacoin_elements se INNER JOIN sia_addresses sa ON (se.address_id = sa.id) - WHERE se.address_id IN (SELECT address_id FROM wallet_addresses WHERE wallet_id=$1)` + WHERE se.address_id IN (SELECT address_id FROM wallet_addresses WHERE wallet_id=$1) + LIMIT $2 OFFSET $3` - rows, err := tx.Query(query, walletID) + rows, err := tx.Query(query, id, limit, offset) if err != nil { return err } @@ -251,20 +308,25 @@ func (s *Store) UnspentSiacoinOutputs(walletID string) (siacoins []types.Siacoin siacoins = append(siacoins, siacoin) } - return nil + return rows.Err() }) return } -// UnspentSiafundOutputs returns the unspent siafund outputs for a wallet. -func (s *Store) UnspentSiafundOutputs(walletID string) (siafunds []types.SiafundElement, err error) { +// WalletSiafundOutputs returns the unspent siafund outputs for a wallet. +func (s *Store) WalletSiafundOutputs(id wallet.ID, offset, limit int) (siafunds []types.SiafundElement, err error) { err = s.transaction(func(tx *txn) error { + if err := walletExists(tx, id); err != nil { + return err + } + const query = `SELECT se.id, se.leaf_index, se.merkle_proof, se.siafund_value, se.claim_start, sa.sia_address FROM siafund_elements se INNER JOIN sia_addresses sa ON (se.address_id = sa.id) - WHERE se.address_id IN (SELECT address_id FROM wallet_addresses WHERE wallet_id=$1)` + WHERE se.address_id IN (SELECT address_id FROM wallet_addresses WHERE wallet_id=$1) + LIMIT $2 OFFSET $3` - rows, err := tx.Query(query, walletID) + rows, err := tx.Query(query, id, limit, offset) if err != nil { return err } @@ -278,22 +340,27 @@ func (s *Store) UnspentSiafundOutputs(walletID string) (siafunds []types.Siafund } siafunds = append(siafunds, siafund) } - return nil + return rows.Err() }) return } // WalletBalance returns the total balance of a wallet. -func (s *Store) WalletBalance(walletID string) (balance wallet.Balance, err error) { +func (s *Store) WalletBalance(id wallet.ID) (balance wallet.Balance, err error) { err = s.transaction(func(tx *txn) error { + if err := walletExists(tx, id); err != nil { + return err + } + const query = `SELECT siacoin_balance, immature_siacoin_balance, siafund_balance FROM sia_addresses sa INNER JOIN wallet_addresses wa ON (sa.id = wa.address_id) WHERE wa.wallet_id=$1` - rows, err := tx.Query(query, walletID) + rows, err := tx.Query(query, id) if err != nil { return err } + defer rows.Close() for rows.Next() { var addressSC types.Currency @@ -307,23 +374,18 @@ func (s *Store) WalletBalance(walletID string) (balance wallet.Balance, err erro balance.ImmatureSiacoins = balance.ImmatureSiacoins.Add(addressISC) balance.Siafunds += addressSF } - return nil - }) - return -} - -// AddressBalance returns the balance of a single address. -func (s *Store) AddressBalance(address types.Address) (balance wallet.Balance, err error) { - err = s.transaction(func(tx *txn) error { - const query = `SELECT siacoin_balance, immature_siacoin_balance, siafund_balance FROM sia_addresses WHERE sia_address=$1` - return tx.QueryRow(query, encode(address)).Scan(decode(&balance.Siacoins), decode(&balance.ImmatureSiacoins), &balance.Siafunds) + return rows.Err() }) return } // Annotate annotates a list of transactions using the wallet's addresses. -func (s *Store) Annotate(walletID string, txns []types.Transaction) (annotated []wallet.PoolTransaction, err error) { +func (s *Store) Annotate(id wallet.ID, txns []types.Transaction) (annotated []wallet.PoolTransaction, err error) { err = s.transaction(func(tx *txn) error { + if err := walletExists(tx, id); err != nil { + return err + } + const query = `SELECT sa.id FROM sia_addresses sa INNER JOIN wallet_addresses wa ON (sa.id = wa.address_id) WHERE wa.wallet_id=$1 AND sa.sia_address=$2 LIMIT 1` @@ -341,7 +403,7 @@ WHERE wa.wallet_id=$1 AND sa.sia_address=$2 LIMIT 1` // addresses into memory. ownsAddress := func(address types.Address) bool { var dbID int64 - err := stmt.QueryRow(walletID, encode(address)).Scan(dbID) + err := stmt.QueryRow(id, encode(address)).Scan(dbID) if err != nil && !errors.Is(err, sql.ErrNoRows) { panic(err) // database error } @@ -358,3 +420,13 @@ WHERE wa.wallet_id=$1 AND sa.sia_address=$2 LIMIT 1` }) return } + +func walletExists(tx *txn, id wallet.ID) error { + const query = `SELECT id FROM wallets WHERE id=$1` + var dummyID int64 + err := tx.QueryRow(query, id).Scan(&dummyID) + if errors.Is(err, sql.ErrNoRows) { + return wallet.ErrNotFound + } + return err +} diff --git a/persist/sqlite/wallet_test.go b/persist/sqlite/wallet_test.go new file mode 100644 index 0000000..5c1e960 --- /dev/null +++ b/persist/sqlite/wallet_test.go @@ -0,0 +1,106 @@ +package sqlite + +import ( + "encoding/json" + "path/filepath" + "testing" + + "go.sia.tech/core/types" + "go.sia.tech/walletd/wallet" + "go.uber.org/zap/zaptest" +) + +func TestWalletAddresses(t *testing.T) { + log := zaptest.NewLogger(t) + db, err := OpenDatabase(filepath.Join(t.TempDir(), "walletd.sqlite3"), log.Named("sqlite3")) + if err != nil { + t.Fatal(err) + } + defer db.Close() + + // Add a wallet + w, err := db.AddWallet(wallet.Wallet{Name: "test"}) + if err != nil { + t.Fatal(err) + } + + wallets, err := db.Wallets() + if err != nil { + t.Fatal(err) + } else if len(wallets) != 1 { + t.Fatal("expected 1 wallet, got", len(wallets)) + } else if wallets[0].ID != w.ID { + t.Fatal("unexpected wallet ID", wallets[0].ID) + } else if wallets[0].Name != "test" { + t.Fatal("unexpected wallet name", wallets[0].Name) + } else if wallets[0].Metadata != nil { + t.Fatal("unexpected metadata", wallets[0].Metadata) + } + + // Add an address + pk := types.GeneratePrivateKey() + spendPolicy := types.PolicyPublicKey(pk.PublicKey()) + address := spendPolicy.Address() + + addr := wallet.Address{ + Address: address, + SpendPolicy: &spendPolicy, + Description: "hello, world", + } + err = db.AddWalletAddress(w.ID, addr) + if err != nil { + t.Fatal(err) + } + + // Check that the address was added + addresses, err := db.WalletAddresses(w.ID) + if err != nil { + t.Fatal(err) + } else if len(addresses) != 1 { + t.Fatal("expected 1 address, got", len(addresses)) + } else if addresses[0].Address != address { + t.Fatal("unexpected address", addresses[0].Address) + } else if addresses[0].Description != "hello, world" { + t.Fatal("unexpected description", addresses[0].Description) + } else if *addresses[0].SpendPolicy != spendPolicy { + t.Fatal("unexpected spend policy", addresses[0].SpendPolicy) + } + + // update the addresses metadata and description + addr.Description = "goodbye, world" + addr.Metadata = json.RawMessage(`{"foo": "bar"}`) + + if err := db.AddWalletAddress(w.ID, addr); err != nil { + t.Fatal(err) + } + + // Check that the address was added + addresses, err = db.WalletAddresses(w.ID) + if err != nil { + t.Fatal(err) + } else if len(addresses) != 1 { + t.Fatal("expected 1 address, got", len(addresses)) + } else if addresses[0].Address != address { + t.Fatal("unexpected address", addresses[0].Address) + } else if addresses[0].Description != "goodbye, world" { + t.Fatal("unexpected description", addresses[0].Description) + } else if *addresses[0].SpendPolicy != spendPolicy { + t.Fatal("unexpected spend policy", addresses[0].SpendPolicy) + } else if string(addresses[0].Metadata) != `{"foo": "bar"}` { + t.Fatal("unexpected metadata", addresses[0].Metadata) + } + + // Remove the address + err = db.RemoveWalletAddress(w.ID, address) + if err != nil { + t.Fatal(err) + } + + // Check that the address was removed + addresses, err = db.WalletAddresses(w.ID) + if err != nil { + t.Fatal(err) + } else if len(addresses) != 0 { + t.Fatal("expected 0 addresses, got", len(addresses)) + } +} diff --git a/wallet/manager.go b/wallet/manager.go index 8ecb5b3..425f264 100644 --- a/wallet/manager.go +++ b/wallet/manager.go @@ -1,7 +1,6 @@ package wallet import ( - "encoding/json" "errors" "fmt" "sync" @@ -25,20 +24,20 @@ type ( Store interface { chain.Subscriber - WalletEvents(name string, offset, limit int) ([]Event, error) - AddWallet(name string, info json.RawMessage) error - DeleteWallet(name string) error - Wallets() (map[string]json.RawMessage, error) + WalletEvents(walletID ID, offset, limit int) ([]Event, error) + AddWallet(Wallet) (Wallet, error) + UpdateWallet(Wallet) (Wallet, error) + DeleteWallet(walletID ID) error + WalletBalance(walletID ID) (Balance, error) + WalletSiacoinOutputs(walletID ID, offset, limit int) ([]types.SiacoinElement, error) + WalletSiafundOutputs(walletID ID, offset, limit int) ([]types.SiafundElement, error) + WalletAddresses(walletID ID) ([]Address, error) + Wallets() ([]Wallet, error) - AddAddress(walletID string, address types.Address, info json.RawMessage) error - RemoveAddress(walletID string, address types.Address) error - Addresses(walletID string) (map[types.Address]json.RawMessage, error) - UnspentSiacoinOutputs(walletID string) ([]types.SiacoinElement, error) - UnspentSiafundOutputs(walletID string) ([]types.SiafundElement, error) - Annotate(walletID string, txns []types.Transaction) ([]PoolTransaction, error) - WalletBalance(walletID string) (Balance, error) + AddWalletAddress(walletID ID, address Address) error + RemoveWalletAddress(walletID ID, address types.Address) error - AddressBalance(address types.Address) (Balance, error) + Annotate(walletID ID, txns []types.Transaction) ([]PoolTransaction, error) LastCommittedIndex() (types.ChainIndex, error) } @@ -55,65 +54,66 @@ type ( ) // AddWallet adds the given wallet. -func (m *Manager) AddWallet(name string, info json.RawMessage) error { - return m.store.AddWallet(name, info) +func (m *Manager) AddWallet(w Wallet) (Wallet, error) { + return m.store.AddWallet(w) +} + +// UpdateWallet updates the given wallet. +func (m *Manager) UpdateWallet(w Wallet) (Wallet, error) { + return m.store.UpdateWallet(w) } // DeleteWallet deletes the given wallet. -func (m *Manager) DeleteWallet(name string) error { - return m.store.DeleteWallet(name) +func (m *Manager) DeleteWallet(walletID ID) error { + return m.store.DeleteWallet(walletID) } // Wallets returns the wallets of the wallet manager. -func (m *Manager) Wallets() (map[string]json.RawMessage, error) { +func (m *Manager) Wallets() ([]Wallet, error) { return m.store.Wallets() } // AddAddress adds the given address to the given wallet. -func (m *Manager) AddAddress(name string, addr types.Address, info json.RawMessage) error { - return m.store.AddAddress(name, addr, info) +func (m *Manager) AddAddress(walletID ID, addr Address) error { + return m.store.AddWalletAddress(walletID, addr) } // RemoveAddress removes the given address from the given wallet. -func (m *Manager) RemoveAddress(name string, addr types.Address) error { - return m.store.RemoveAddress(name, addr) +func (m *Manager) RemoveAddress(walletID ID, addr types.Address) error { + return m.store.RemoveWalletAddress(walletID, addr) } // Addresses returns the addresses of the given wallet. -func (m *Manager) Addresses(name string) (map[types.Address]json.RawMessage, error) { - return m.store.Addresses(name) +func (m *Manager) Addresses(walletID ID) ([]Address, error) { + return m.store.WalletAddresses(walletID) } // Events returns the events of the given wallet. -func (m *Manager) Events(name string, offset, limit int) ([]Event, error) { - return m.store.WalletEvents(name, offset, limit) +func (m *Manager) Events(walletID ID, offset, limit int) ([]Event, error) { + return m.store.WalletEvents(walletID, offset, limit) } -// UnspentSiacoinOutputs returns the unspent siacoin outputs of the given wallet -func (m *Manager) UnspentSiacoinOutputs(name string) ([]types.SiacoinElement, error) { - return m.store.UnspentSiacoinOutputs(name) +// UnspentSiacoinOutputs returns a paginated list of unspent siacoin outputs of +// the given wallet and the total number of unspent siacoin outputs. +func (m *Manager) UnspentSiacoinOutputs(walletID ID, offset, limit int) ([]types.SiacoinElement, error) { + return m.store.WalletSiacoinOutputs(walletID, offset, limit) } // UnspentSiafundOutputs returns the unspent siafund outputs of the given wallet -func (m *Manager) UnspentSiafundOutputs(name string) ([]types.SiafundElement, error) { - return m.store.UnspentSiafundOutputs(name) +func (m *Manager) UnspentSiafundOutputs(walletID ID, offset, limit int) ([]types.SiafundElement, error) { + return m.store.WalletSiafundOutputs(walletID, offset, limit) } // Annotate annotates the given transactions with the wallet they belong to. -func (m *Manager) Annotate(name string, pool []types.Transaction) ([]PoolTransaction, error) { - return m.store.Annotate(name, pool) +func (m *Manager) Annotate(walletID ID, pool []types.Transaction) ([]PoolTransaction, error) { + return m.store.Annotate(walletID, pool) } // WalletBalance returns the balance of the given wallet. -func (m *Manager) WalletBalance(walletID string) (Balance, error) { +func (m *Manager) WalletBalance(walletID ID) (Balance, error) { return m.store.WalletBalance(walletID) } -// AddressBalance returns the balance of the given address. -func (m *Manager) AddressBalance(address types.Address) (Balance, error) { - return m.store.AddressBalance(address) -} - // Reserve reserves the given ids for the given duration. func (m *Manager) Reserve(ids []types.Hash256, duration time.Duration) error { m.mu.Lock() diff --git a/wallet/seed.go b/wallet/seed.go index 09288bb..ecc4a65 100644 --- a/wallet/seed.go +++ b/wallet/seed.go @@ -70,13 +70,19 @@ func (sav *SeedAddressVault) OwnsAddress(addr types.Address) bool { // NewAddress returns a new address derived from the seed, along with // descriptive metadata. -func (sav *SeedAddressVault) NewAddress(desc string) (types.Address, json.RawMessage) { +func (sav *SeedAddressVault) NewAddress(desc string) Address { sav.mu.Lock() defer sav.mu.Unlock() index := uint64(len(sav.addrs)) - sav.lookahead + 1 sav.gen(index + sav.lookahead) - addr := types.StandardAddress(sav.seed.PublicKey(index)) - return addr, json.RawMessage(fmt.Sprintf(`{"desc":"%s","keyIndex":%d}`, desc, index)) + policy := types.PolicyPublicKey(sav.seed.PublicKey(index)) + addr := policy.Address() + return Address{ + Address: addr, + Description: desc, + SpendPolicy: &policy, + Metadata: json.RawMessage(fmt.Sprintf(`{"keyIndex":%d}`, index)), + } } // SignTransaction signs the specified transaction using keys derived from the diff --git a/wallet/wallet.go b/wallet/wallet.go index 510b239..df76f48 100644 --- a/wallet/wallet.go +++ b/wallet/wallet.go @@ -2,7 +2,9 @@ package wallet import ( "encoding/json" + "errors" "fmt" + "strconv" "time" "go.sia.tech/core/consensus" @@ -25,8 +27,47 @@ type ( ImmatureSiacoins types.Currency `json:"immatureSiacoins"` Siafunds uint64 `json:"siafunds"` } + + // An ID is a unique identifier for a wallet. + ID int64 + + // A Wallet is a collection of addresses and metadata. + Wallet struct { + ID ID `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + DateCreated time.Time `json:"dateCreated"` + LastUpdated time.Time `json:"lastUpdated"` + Metadata json.RawMessage `json:"metadata"` + } + + // A Address is an address associated with a wallet. + Address struct { + Address types.Address `json:"address"` + Description string `json:"description"` + SpendPolicy *types.SpendPolicy `json:"spendPolicy,omitempty"` + Metadata json.RawMessage `json:"metadata"` + } ) +// ErrNotFound is returned when a requested wallet or address is not found. +var ErrNotFound = errors.New("not found") + +// UnmarshalText implements encoding.TextUnmarshaler. +func (w *ID) UnmarshalText(buf []byte) error { + id, err := strconv.ParseInt(string(buf), 10, 64) + if err != nil { + return err + } + *w = ID(id) + return nil +} + +// MarshalText implements encoding.TextMarshaler. +func (w ID) MarshalText() ([]byte, error) { + return []byte(strconv.FormatInt(int64(w), 10)), nil +} + // StandardTransactionSignature is the most common form of TransactionSignature. // It covers the entire transaction, references a sole public key, and has no // timelock.