Skip to content

Commit

Permalink
Add /wallet/send endpoint (#1436)
Browse files Browse the repository at this point in the history
This PR adds a new `/wallet/send` endpoint which is a lot easier to use
than the existing 2-step solution.

Other endpoints like the ones for forming and renewing contracts will
also be updated to be single endpoints on the bus rather than multiple.
The existing endpoints will remain in the API until we are ready for a
breaking v2.0.0.

The biggest advantage of this endpoint over the existing one is the fact
that it's type agnostic. Switching from v1 to v2 transactions can happen
without the consumer of the API being aware of the transition.
  • Loading branch information
ChrisSchinnerl authored Aug 15, 2024
2 parents a945624 + 4d39d7d commit c805de6
Show file tree
Hide file tree
Showing 7 changed files with 109 additions and 69 deletions.
7 changes: 7 additions & 0 deletions api/wallet.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,13 @@ type (
Immature types.Currency `json:"immature"`
}

WalletSendRequest struct {
Address types.Address `json:"address"`
Amount types.Currency `json:"amount"`
SubtractMinerFee bool `json:"subtractMinerFee"`
UseUnconfirmed bool `json:"useUnconfirmed"`
}

// WalletSignRequest is the request type for the /wallet/sign endpoint.
WalletSignRequest struct {
Transaction types.Transaction `json:"transaction"`
Expand Down
10 changes: 10 additions & 0 deletions bus/bus.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import (
const (
defaultPinUpdateInterval = 5 * time.Minute
defaultPinRateWindow = 6 * time.Hour
stdTxnSize = 1200 // bytes
)

// Client re-exports the client from the client package.
Expand All @@ -50,15 +51,18 @@ type (
ChainManager interface {
AddBlocks(blocks []types.Block) error
AddPoolTransactions(txns []types.Transaction) (bool, error)
AddV2PoolTransactions(basis types.ChainIndex, txns []types.V2Transaction) (known bool, err error)
Block(id types.BlockID) (types.Block, bool)
OnReorg(fn func(types.ChainIndex)) (cancel func())
PoolTransaction(txid types.TransactionID) (types.Transaction, bool)
PoolTransactions() []types.Transaction
V2PoolTransactions() []types.V2Transaction
RecommendedFee() types.Currency
Tip() types.ChainIndex
TipState() consensus.State
UnconfirmedParents(txn types.Transaction) []types.Transaction
UpdatesSince(index types.ChainIndex, max int) (rus []chain.RevertUpdate, aus []chain.ApplyUpdate, err error)
V2UnconfirmedParents(txn types.V2Transaction) []types.V2Transaction
}

ChainSubscriber interface {
Expand Down Expand Up @@ -206,7 +210,9 @@ type (
Syncer interface {
Addr() string
BroadcastHeader(h gateway.BlockHeader)
BroadcastV2BlockOutline(bo gateway.V2BlockOutline)
BroadcastTransactionSet([]types.Transaction)
BroadcastV2TransactionSet(index types.ChainIndex, txns []types.V2Transaction)
Connect(ctx context.Context, addr string) (*syncer.Peer, error)
Peers() []*syncer.Peer
}
Expand All @@ -216,9 +222,12 @@ type (
Balance() (wallet.Balance, error)
Close() error
FundTransaction(txn *types.Transaction, amount types.Currency, useUnconfirmed bool) ([]types.Hash256, error)
FundV2Transaction(txn *types.V2Transaction, amount types.Currency, useUnconfirmed bool) (consensus.State, []int, error)
Redistribute(outputs int, amount, feePerByte types.Currency) (txns []types.Transaction, toSign []types.Hash256, err error)
RedistributeV2(outputs int, amount, feePerByte types.Currency) (txns []types.V2Transaction, toSign [][]int, err error)
ReleaseInputs(txns []types.Transaction, v2txns []types.V2Transaction)
SignTransaction(txn *types.Transaction, toSign []types.Hash256, cf types.CoveredFields)
SignV2Inputs(state consensus.State, txn *types.V2Transaction, toSign []int)
SpendableOutputs() ([]types.SiacoinElement, error)
Tip() (types.ChainIndex, error)
UnconfirmedEvents() ([]wallet.Event, error)
Expand Down Expand Up @@ -437,6 +446,7 @@ func (b *bus) Handler() http.Handler {
"POST /wallet/prepare/form": b.walletPrepareFormHandler,
"POST /wallet/prepare/renew": b.walletPrepareRenewHandler,
"POST /wallet/redistribute": b.walletRedistributeHandler,
"POST /wallet/send": b.walletSendSiacoinsHandler,
"POST /wallet/sign": b.walletSignHandler,
"GET /wallet/transactions": b.walletTransactionsHandler,

Expand Down
32 changes: 9 additions & 23 deletions bus/client/wallet.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,29 +12,15 @@ import (
"go.sia.tech/renterd/api"
)

// SendSiacoins is a helper method that sends siacoins to the given outputs.
func (c *Client) SendSiacoins(ctx context.Context, scos []types.SiacoinOutput, useUnconfirmedTxns bool) (err error) {
var value types.Currency
for _, sco := range scos {
value = value.Add(sco.Value)
}
txn := types.Transaction{
SiacoinOutputs: scos,
}
toSign, parents, err := c.WalletFund(ctx, &txn, value, useUnconfirmedTxns)
if err != nil {
return err
}
defer func() {
if err != nil {
_ = c.WalletDiscard(ctx, txn)
}
}()
err = c.WalletSign(ctx, &txn, toSign, types.CoveredFields{WholeTransaction: true})
if err != nil {
return err
}
return c.BroadcastTransaction(ctx, append(parents, txn))
// SendSiacoins is a helper method that sends siacoins to the given address.
func (c *Client) SendSiacoins(ctx context.Context, addr types.Address, amt types.Currency, useUnconfirmedTxns bool) (txnID types.TransactionID, err error) {
err = c.c.WithContext(ctx).POST("/wallet/send", api.WalletSendRequest{
Address: addr,
Amount: amt,
SubtractMinerFee: false,
UseUnconfirmed: useUnconfirmedTxns,
}, &txnID)
return
}

// Wallet calls the /wallet endpoint on the bus.
Expand Down
75 changes: 75 additions & 0 deletions bus/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ func (b *bus) consensusAcceptBlock(jc jape.Context) {
Timestamp: block.Timestamp,
MerkleRoot: block.MerkleRoot(),
})
} else {
b.s.BroadcastV2BlockOutline(gateway.OutlineBlock(block, b.cm.PoolTransactions(), b.cm.V2PoolTransactions()))
}
}

Expand Down Expand Up @@ -331,6 +333,79 @@ func (b *bus) walletFundHandler(jc jape.Context) {
})
}

func (b *bus) walletSendSiacoinsHandler(jc jape.Context) {
var req api.WalletSendRequest
if jc.Decode(&req) != nil {
return
} else if req.Address == types.VoidAddress {
jc.Error(errors.New("cannot send to void address"), http.StatusBadRequest)
return
}

// estimate miner fee
feePerByte := b.cm.RecommendedFee()
minerFee := feePerByte.Mul64(stdTxnSize)
if req.SubtractMinerFee {
var underflow bool
req.Amount, underflow = req.Amount.SubWithUnderflow(minerFee)
if underflow {
jc.Error(fmt.Errorf("amount must be greater than miner fee: %s", minerFee), http.StatusBadRequest)
return
}
}

state := b.cm.TipState()
// if the current height is below the v2 hardfork height, send a v1
// transaction
if state.Index.Height < state.Network.HardforkV2.AllowHeight {
// build transaction
txn := types.Transaction{
MinerFees: []types.Currency{minerFee},
SiacoinOutputs: []types.SiacoinOutput{
{Address: req.Address, Value: req.Amount},
},
}
toSign, err := b.w.FundTransaction(&txn, req.Amount.Add(minerFee), req.UseUnconfirmed)
if jc.Check("failed to fund transaction", err) != nil {
return
}
b.w.SignTransaction(&txn, toSign, types.CoveredFields{WholeTransaction: true})
// shouldn't be necessary to get parents since the transaction is
// not using unconfirmed outputs, but good practice
txnset := append(b.cm.UnconfirmedParents(txn), txn)
// verify the transaction and add it to the transaction pool
if _, err := b.cm.AddPoolTransactions(txnset); jc.Check("failed to add transaction set", err) != nil {
b.w.ReleaseInputs([]types.Transaction{txn}, nil)
return
}
// broadcast the transaction
b.s.BroadcastTransactionSet(txnset)
jc.Encode(txn.ID())
} else {
txn := types.V2Transaction{
MinerFee: minerFee,
SiacoinOutputs: []types.SiacoinOutput{
{Address: req.Address, Value: req.Amount},
},
}
// fund and sign transaction
state, toSign, err := b.w.FundV2Transaction(&txn, req.Amount.Add(minerFee), req.UseUnconfirmed)
if jc.Check("failed to fund transaction", err) != nil {
return
}
b.w.SignV2Inputs(state, &txn, toSign)
txnset := append(b.cm.V2UnconfirmedParents(txn), txn)
// verify the transaction and add it to the transaction pool
if _, err := b.cm.AddV2PoolTransactions(state.Index, txnset); jc.Check("failed to add v2 transaction set", err) != nil {
b.w.ReleaseInputs(nil, []types.V2Transaction{txn})
return
}
// broadcast the transaction
b.s.BroadcastV2TransactionSet(state.Index, txnset)
jc.Encode(txn.ID())
}
}

func (b *bus) walletSignHandler(jc jape.Context) {
var wsr api.WalletSignRequest
if jc.Decode(&wsr) != nil {
Expand Down
13 changes: 1 addition & 12 deletions internal/node/syncer.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,29 +4,18 @@ import (
"context"
"errors"
"fmt"
"io"
"net"
"time"

"go.sia.tech/core/gateway"
"go.sia.tech/core/types"
"go.sia.tech/coreutils/syncer"
"go.uber.org/zap"
)

type Syncer interface {
io.Closer
Addr() string
BroadcastHeader(h gateway.BlockHeader)
BroadcastTransactionSet([]types.Transaction)
Connect(ctx context.Context, addr string) (*syncer.Peer, error)
Peers() []*syncer.Peer
}

// NewSyncer creates a syncer using the given configuration. The syncer that is
// returned is already running, closing it will close the underlying listener
// causing the syncer to stop.
func NewSyncer(cfg BusConfig, cm syncer.ChainManager, ps syncer.PeerStore, logger *zap.Logger) (Syncer, error) {
func NewSyncer(cfg BusConfig, cm syncer.ChainManager, ps syncer.PeerStore, logger *zap.Logger) (*syncer.Syncer, error) {
// validate config
if cfg.Bootstrap && cfg.Network == nil {
return nil, errors.New("cannot bootstrap without a network")
Expand Down
9 changes: 1 addition & 8 deletions internal/test/e2e/cluster.go
Original file line number Diff line number Diff line change
Expand Up @@ -697,14 +697,7 @@ func (c *TestCluster) AddHost(h *Host) {

// Fund host from bus.
fundAmt := types.Siacoins(25e3)
var scos []types.SiacoinOutput
for i := 0; i < 10; i++ {
scos = append(scos, types.SiacoinOutput{
Value: fundAmt.Div64(10),
Address: h.WalletAddress(),
})
}
c.tt.OK(c.Bus.SendSiacoins(context.Background(), scos, true))
c.tt.OKAll(c.Bus.SendSiacoins(context.Background(), h.WalletAddress(), fundAmt, true))

// Mine transaction.
c.MineBlocks(1)
Expand Down
32 changes: 6 additions & 26 deletions internal/test/e2e/cluster_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2329,13 +2329,8 @@ func TestWalletSendUnconfirmed(t *testing.T) {
}

// send the full balance back to the weallet
toSend := wr.Confirmed.Sub(types.Siacoins(1).Div64(100)) // leave some for the fee
tt.OK(b.SendSiacoins(context.Background(), []types.SiacoinOutput{
{
Address: wr.Address,
Value: toSend,
},
}, false))
toSend := wr.Confirmed.Sub(types.Siacoins(1)) // leave some for the fee
tt.OKAll(b.SendSiacoins(context.Background(), wr.Address, toSend, false))

// the unconfirmed balance should have changed to slightly more than toSend
// since we paid a fee
Expand All @@ -2348,21 +2343,11 @@ func TestWalletSendUnconfirmed(t *testing.T) {
fmt.Println(wr.Confirmed, wr.Unconfirmed)

// try again - this should fail
err = b.SendSiacoins(context.Background(), []types.SiacoinOutput{
{
Address: wr.Address,
Value: toSend,
},
}, false)
_, err = b.SendSiacoins(context.Background(), wr.Address, toSend, false)
tt.AssertIs(err, wallet.ErrNotEnoughFunds)

// try again - this time using unconfirmed transactions
tt.OK(b.SendSiacoins(context.Background(), []types.SiacoinOutput{
{
Address: wr.Address,
Value: toSend,
},
}, true))
tt.OKAll(b.SendSiacoins(context.Background(), wr.Address, toSend, true))

// the unconfirmed balance should be almost the same
wr, err = b.Wallet(context.Background())
Expand Down Expand Up @@ -2403,15 +2388,10 @@ func TestWalletFormUnconfirmed(t *testing.T) {
cluster.AddHosts(1)

// send all money to ourselves, making sure it's unconfirmed
feeReserve := types.Siacoins(1).Div64(100)
feeReserve := types.Siacoins(1)
wr, err := b.Wallet(context.Background())
tt.OK(err)
tt.OK(b.SendSiacoins(context.Background(), []types.SiacoinOutput{
{
Address: wr.Address,
Value: wr.Confirmed.Sub(feeReserve), // leave some for the fee
},
}, false))
tt.OKAll(b.SendSiacoins(context.Background(), wr.Address, wr.Confirmed.Sub(feeReserve), false)) // leave some for the fee

// check wallet only has the reserve in the confirmed balance
wr, err = b.Wallet(context.Background())
Expand Down

0 comments on commit c805de6

Please sign in to comment.