Skip to content

Commit

Permalink
client: handle redemption funding
Browse files Browse the repository at this point in the history
client/asset:
Add AccounterRedeemer interface with methods for locking and unlocking
funds for redemption. ReserveN is used to reserve funds for an order.
Only the amount reserved is returned. All accounting from there is
handled by Core, who will return funds proportionally according to
match quantities and order size. The amount reserved is persisted in
the OrderMetaData so that accounting can be picked up exactly as it was on restarts.
This pattern was chosen over trying to figure out how much to lock
on restart with calculations based on order info because you would need
to consider changes in asset configuration and therefore persist more
data and ... it gets messy.

client/core:
Add RedeemSig to orders redeeming to and AccountRedeemer.
Track and return reserved redemption funds. Return funds manually
for refunds and revocations. Handle dust that can arise from rounding
error. Log inconsistencies.

client/asset/eth:
Implement asset.AccountRedeemer. Track redemption reserves separately
than swap reserves, but add them when calculating locked balance.
  • Loading branch information
buck54321 committed Jan 6, 2022
1 parent 986c765 commit 85764f3
Show file tree
Hide file tree
Showing 11 changed files with 901 additions and 153 deletions.
81 changes: 69 additions & 12 deletions client/asset/eth/eth.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ import (
"github.com/ethereum/go-ethereum/accounts/keystore"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/core/types"
"github.com/ethereum/go-ethereum/crypto"
)

func init() {
Expand Down Expand Up @@ -171,14 +170,15 @@ type ethFetcher interface {
lock() error
locked() bool
unlock(pw string) error
signData(addr common.Address, data []byte) ([]byte, error)
signData(data []byte) (sig, pubKey []byte, err error)
sendToAddr(ctx context.Context, addr common.Address, val uint64) (*types.Transaction, error)
transactionConfirmations(context.Context, common.Hash) (uint32, error)
sendSignedTransaction(ctx context.Context, tx *types.Transaction) error
}

// Check that ExchangeWallet satisfies the asset.Wallet interface.
var _ asset.Wallet = (*ExchangeWallet)(nil)
var _ asset.AccountRedeemer = (*ExchangeWallet)(nil)

// ExchangeWallet is a wallet backend for Ethereum. The backend is how the DEX
// client app communicates with the Ethereum blockchain and wallet. ExchangeWallet
Expand All @@ -199,8 +199,9 @@ type ExchangeWallet struct {
tipMtx sync.RWMutex
currentTip *types.Block

locked uint64
lockedMtx sync.RWMutex
lockedMtx sync.RWMutex
locked uint64
redemptionReserve uint64

findRedemptionMtx sync.RWMutex
findRedemptionReqs map[[32]byte]*findRedemptionRequest
Expand Down Expand Up @@ -354,7 +355,7 @@ func (eth *ExchangeWallet) balance() (*asset.Balance, error) {
return nil, err
}

locked := eth.locked + dexeth.WeiToGwei(bal.PendingOut)
locked := eth.locked + eth.redemptionReserve + dexeth.WeiToGwei(bal.PendingOut)
return &asset.Balance{
Available: dexeth.WeiToGwei(bal.Current) - locked,
Locked: locked,
Expand Down Expand Up @@ -699,7 +700,7 @@ func (eth *ExchangeWallet) Redeem(form *asset.RedeemForm) ([]dex.Bytes, asset.Co

var contractVersion uint32 // require a consistent version since this is a single transaction
inputs := make([]dex.Bytes, 0, len(form.Redemptions))
var redeemedValue uint64
var redeemedValue, unlocked uint64
for i, redemption := range form.Redemptions {
// NOTE: redemption.Spends.SecretHash is a dup of the hash extracted
// from redemption.Spends.Contract. Even for scriptable UTXO assets, the
Expand Down Expand Up @@ -736,6 +737,7 @@ func (eth *ExchangeWallet) Redeem(form *asset.RedeemForm) ([]dex.Bytes, asset.Co
return nil, nil, 0, fmt.Errorf("Redeem: error finding swap state: %w", err)
}
redeemedValue += swapData.Value
unlocked += redemption.UnlockedReserves
inputs = append(inputs, redemption.Spends.Coin.ID())
}

Expand All @@ -749,6 +751,16 @@ func (eth *ExchangeWallet) Redeem(form *asset.RedeemForm) ([]dex.Bytes, asset.Co
return fail(fmt.Errorf("Redeem: redeem error: %w", err))
}

eth.lockedMtx.Lock()
defer eth.lockedMtx.Unlock()

if unlocked > eth.redemptionReserve {
eth.log.Errorf("attempted to unlock redemption funds > reserves")
eth.redemptionReserve = 0
} else {
eth.redemptionReserve -= unlocked
}

return inputs, outputCoin, fundsRequired, nil
}

Expand All @@ -770,6 +782,56 @@ func recoverPubkey(msgHash, sig []byte) ([]byte, error) {
return pubKey.SerializeUncompressed(), nil
}

// ReserveN locks funds for redemption. It is an error if there is insufficient
// spendable balance. Part of the AccountRedeemer interface.
func (eth *ExchangeWallet) ReserveN(n, feeRate uint64, assetVer uint32) (uint64, error) {
redeemCost := dexeth.RedeemGas(1, assetVer) * feeRate
reserve := redeemCost * n

eth.lockedMtx.Lock()
defer eth.lockedMtx.Unlock()
bal, err := eth.balance()
if err != nil {
return 0, fmt.Errorf("error retreiving balance: %w", err)
}
if reserve > bal.Available {
return 0, fmt.Errorf("balance too low. %d < %d", bal.Available, reserve)
} else {
eth.redemptionReserve += reserve
}

return reserve, err
}

// UnlockReserves unlocks the specified amount from redemption reserves. Part
// of the AccountRedeemer interface.
func (eth *ExchangeWallet) UnlockReserves(reserves uint64) {
eth.lockedMtx.Lock()
if reserves > eth.redemptionReserve {
eth.redemptionReserve = 0
eth.log.Errorf("attempting to unlock more than reserved. %d > %d", reserves, eth.redemptionReserve)
} else {
eth.redemptionReserve -= reserves
}
eth.lockedMtx.Unlock()
}

// ReReserve checks out an amount for redemptions. Use ReReserve after
// initializing a new ExchangeWallet.
func (eth *ExchangeWallet) ReReserve(req uint64) error {
eth.lockedMtx.Lock()
defer eth.lockedMtx.Unlock()
bal, err := eth.balance()
if err != nil {
return err
}
if eth.redemptionReserve+req > bal.Available {
return fmt.Errorf("not enough funds. %d < %d", eth.redemptionReserve+req, bal.Available)
}
eth.redemptionReserve += req
return nil
}

// SignMessage signs the message with the private key associated with the
// specified funding Coin. Only a coin that came from the address this wallet
// is initialized with can be used to sign.
Expand All @@ -779,16 +841,11 @@ func (eth *ExchangeWallet) SignMessage(coin asset.Coin, msg dex.Bytes) (pubkeys,
return nil, nil, fmt.Errorf("SignMessage: error decoding coin: %w", err)
}

sig, err := eth.node.signData(eth.addr, msg)
sig, pubKey, err := eth.node.signData(msg)
if err != nil {
return nil, nil, fmt.Errorf("SignMessage: error signing data: %w", err)
}

pubKey, err := recoverPubkey(crypto.Keccak256(msg), sig)
if err != nil {
return nil, nil, fmt.Errorf("SignMessage: error recovering pubkey %w", err)
}

return []dex.Bytes{pubKey}, []dex.Bytes{sig}, nil
}

Expand Down
91 changes: 87 additions & 4 deletions client/asset/eth/eth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -175,16 +175,22 @@ func (n *testNode) swap(ctx context.Context, secretHash [32]byte, contractVer ui
}
return swap, nil
}
func (n *testNode) signData(addr common.Address, data []byte) ([]byte, error) {

func (n *testNode) signData(data []byte) (sig, pubKey []byte, err error) {
if n.signDataErr != nil {
return nil, n.signDataErr
return nil, nil, n.signDataErr
}

if n.privKeyForSigning == nil {
return nil, nil
return nil, nil, nil
}

return crypto.Sign(crypto.Keccak256(data), n.privKeyForSigning)
sig, err = crypto.Sign(crypto.Keccak256(data), n.privKeyForSigning)
if err != nil {
return nil, nil, err
}

return sig, crypto.FromECDSAPub(&n.privKeyForSigning.PublicKey), nil
}
func (n *testNode) sendSignedTransaction(ctx context.Context, tx *types.Transaction) error {
return nil
Expand Down Expand Up @@ -2358,6 +2364,83 @@ func TestFindRedemption(t *testing.T) {
}
}

func TestRedemptionReserves(t *testing.T) {
node := newTestNode(nil)
node.bal = newBalance(1e9, 0, 0)
node.redeemable = true
node.swapVers = map[uint32]struct{}{0: {}}

var secretHash [32]byte
node.swapMap = map[[32]byte]*dexeth.SwapState{secretHash: {}}
spentCoin := &coin{id: encode.RandomBytes(32)}

eth := &ExchangeWallet{
node: node,
}

var maxFeeRateV0 uint64 = 45
gasesV0 := dexeth.VersionedGases[0]

gasesV1 := &dexeth.Gases{RedeemGas: 1e6, AdditionalRedeemGas: 85e5}
dexeth.VersionedGases[1] = gasesV1
var maxFeeRateV1 uint64 = 50

v0Val, err := eth.ReserveN(3, maxFeeRateV0, 0)
if err != nil {
t.Fatalf("reservation error: %v", err)
}

lockPerV0 := gasesV0.RedeemGas * maxFeeRateV0
expLock := 3 * lockPerV0
if eth.redemptionReserve != expLock {
t.Fatalf("wrong v0 locked. wanted %d, got %d", expLock, eth.redemptionReserve)
}

if v0Val != expLock {
t.Fatalf("expected value %d, got %d", lockPerV0, v0Val)
}

v1Val, err := eth.ReserveN(2, maxFeeRateV1, 1)
if err != nil {
t.Fatalf("reservation error: %v", err)
}

lockPerV1 := gasesV1.RedeemGas * maxFeeRateV1
v1Lock := 2 * lockPerV1
if v1Val != v1Lock {
t.Fatalf("")
}

expLock += v1Lock
if eth.redemptionReserve != expLock {
t.Fatalf("wrong v1 locked. wanted %d, got %d", expLock, eth.redemptionReserve)
}

// Redeem two v0.
if _, _, _, err = eth.Redeem(&asset.RedeemForm{
Redemptions: []*asset.Redemption{{
Spends: &asset.AuditInfo{
Coin: spentCoin,
Contract: dexeth.EncodeContractData(0, secretHash),
},
UnlockedReserves: lockPerV0,
}, {
Spends: &asset.AuditInfo{
Coin: spentCoin,
Contract: dexeth.EncodeContractData(0, secretHash),
},
UnlockedReserves: lockPerV0,
}},
}); err != nil {
t.Fatalf("Redeem error: %v", err)
}

expLock -= lockPerV0 * 2
if eth.redemptionReserve != expLock {
t.Fatalf("wrong unreserved. wanted %d, got %d", expLock, eth.redemptionReserve)
}
}

func ethToGwei(v uint64) uint64 {
return v * dexeth.GweiFactor
}
Expand Down
33 changes: 24 additions & 9 deletions client/asset/eth/nodeclient.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,12 @@ import (
dexeth "decred.org/dcrdex/dex/networks/eth"
ethv0 "decred.org/dcrdex/dex/networks/eth/contracts/v0"
"github.com/ethereum/go-ethereum"
"github.com/ethereum/go-ethereum/accounts"
"github.com/ethereum/go-ethereum/accounts/abi"
"github.com/ethereum/go-ethereum/accounts/abi/bind"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/core/state"
"github.com/ethereum/go-ethereum/core/types"
"github.com/ethereum/go-ethereum/crypto"
"github.com/ethereum/go-ethereum/eth/ethconfig"
"github.com/ethereum/go-ethereum/ethclient"
"github.com/ethereum/go-ethereum/les"
Expand Down Expand Up @@ -362,23 +362,38 @@ func (n *nodeClient) swap(ctx context.Context, secretHash [32]byte, contractVer
}

// signData uses the private key of the address to sign a piece of data.
// The address must have been imported and unlocked to use this function.
func (n *nodeClient) signData(addr common.Address, data []byte) ([]byte, error) {
// The mime type argument to SignData is not used in the keystore wallet in geth.
// It treats any data like plain text.
return n.creds.wallet.SignData(*n.creds.acct, accounts.MimetypeTextPlain, data)
// The wallet must be unlocked to use this function.
func (n *nodeClient) signData(data []byte) (sig, pubKey []byte, err error) {
sig, err = n.creds.ks.SignHash(*n.creds.acct, crypto.Keccak256(data))
if err != nil {
return nil, nil, err
}
if len(sig) != 65 {
return nil, nil, fmt.Errorf("unexpected signature length %d", len(sig))
}

pubKey, err = recoverPubkey(crypto.Keccak256(data), sig)
if err != nil {
return nil, nil, fmt.Errorf("SignMessage: error recovering pubkey %w", err)
}

// Lop off the "recovery id", since we already recovered the pub key and
// it's not used for validation.
sig = sig[:64]

return
}

func (n *nodeClient) addSignerToOpts(txOpts *bind.TransactOpts) error {
txOpts.Signer = func(addr common.Address, tx *types.Transaction) (*types.Transaction, error) {
return n.creds.wallet.SignTx(accounts.Account{Address: addr}, tx, n.chainID)
return n.creds.wallet.SignTx(*n.creds.acct, tx, n.chainID)
}
return nil
}

// signTransaction signs a transaction.
func (n *nodeClient) signTransaction(addr common.Address, tx *types.Transaction) (*types.Transaction, error) {
return n.creds.ks.SignTx(accounts.Account{Address: addr}, tx, n.chainID)
func (n *nodeClient) signTransaction(tx *types.Transaction) (*types.Transaction, error) {
return n.creds.ks.SignTx(*n.creds.acct, tx, n.chainID)
}

// initiate initiates multiple swaps in the same transaction.
Expand Down
10 changes: 3 additions & 7 deletions client/asset/eth/nodeclient_harness_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -459,7 +459,7 @@ func testSendSignedTransaction(t *testing.T) {
Value: dexeth.GweiToWei(1),
Data: []byte{},
})
tx, err = ethClient.signTransaction(simnetAddr, tx)
tx, err = ethClient.signTransaction(tx)

err = ethClient.sendSignedTransaction(ctx, tx)
if err != nil {
Expand Down Expand Up @@ -2164,14 +2164,10 @@ func testSignMessage(t *testing.T) {
if err != nil {
t.Fatalf("error unlocking account: %v", err)
}
signature, err := ethClient.signData(simnetAddr, msg)
sig, pubKey, err := ethClient.signData(msg)
if err != nil {
t.Fatalf("error signing text: %v", err)
}
pubKey, err := recoverPubkey(crypto.Keccak256(msg), signature)
if err != nil {
t.Fatalf("recoverPubkey: %v", err)
}
x, y := elliptic.Unmarshal(secp256k1.S256(), pubKey)
recoveredAddress := crypto.PubkeyToAddress(ecdsa.PublicKey{
Curve: secp256k1.S256(),
Expand All @@ -2181,7 +2177,7 @@ func testSignMessage(t *testing.T) {
if !bytes.Equal(recoveredAddress.Bytes(), simnetAcct.Address.Bytes()) {
t.Fatalf("recovered address: %v != simnet account address: %v", recoveredAddress, simnetAcct.Address)
}
if !crypto.VerifySignature(pubKey, crypto.Keccak256(msg), signature[:len(signature)-1]) {
if !crypto.VerifySignature(pubKey, crypto.Keccak256(msg), sig) {
t.Fatalf("failed to verify signature")
}
}
Expand Down
17 changes: 17 additions & 0 deletions client/asset/interface.go
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,20 @@ type Wallet interface {
RegFeeConfirmations(ctx context.Context, coinID dex.Bytes) (confs uint32, err error)
}

type AccountRedeemer interface {
// ReserveN is used when preparing funding for an order that redeems to an
// account-based asset. The wallet will set aside the appropriate amount of
// funds so that we can redeem. It is an error to request funds > spendable
// balance.
ReserveN(n, feeRate uint64, assetVer uint32) (uint64, error)
// ReReserve is used when reconstructing existing orders on startup. It is
// an error to request funds > spendable balance.
ReReserve(amt uint64) error
// UnlockReserves is used to return funds when an order is canceled or
// otherwise completed unfilled.
UnlockReserves(uint64)
}

// Balance is categorized information about a wallet's balance.
type Balance struct {
// Available is the balance that is available for trading immediately.
Expand Down Expand Up @@ -330,6 +344,9 @@ type Redemption struct {
Spends *AuditInfo
// Secret is the secret key needed to satisfy the swap contract.
Secret dex.Bytes
// UnlockedReserves is the amount reserved for redemption for account-based
// assets.
UnlockedReserves uint64
}

// RedeemForm is a group of Redemptions. The struct will be
Expand Down
Loading

0 comments on commit 85764f3

Please sign in to comment.