Skip to content

Commit

Permalink
core/eth: handle redemption funding
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
buck54321 committed Dec 6, 2021
1 parent 4ee4c47 commit bf2d28e
Show file tree
Hide file tree
Showing 10 changed files with 379 additions and 118 deletions.
86 changes: 74 additions & 12 deletions client/asset/eth/eth.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,6 @@ import (
"github.com/ethereum/go-ethereum/accounts/abi/bind"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/core/types"
"github.com/ethereum/go-ethereum/crypto"
"github.com/ethereum/go-ethereum/crypto/secp256k1"
)

func init() {
Expand Down Expand Up @@ -151,13 +149,14 @@ type ethFetcher interface {
lock(ctx context.Context) error
locked() bool
unlock(ctx context.Context, 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)
}

// 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 @@ -177,8 +176,9 @@ type ExchangeWallet struct {
tipMtx sync.RWMutex
currentTip *types.Block

lockedFunds map[string]uint64 // gwei
lockedFundsMtx sync.RWMutex
lockedFundsMtx sync.RWMutex
lockedFunds map[string]uint64 // gwei
redemptionReserve uint64
}

// Info returns basic information about the wallet and asset.
Expand Down Expand Up @@ -320,6 +320,8 @@ func (eth *ExchangeWallet) balance() (*asset.Balance, error) {
amountLocked += value
}

amountLocked += eth.redemptionReserve

locked := amountLocked + dexeth.WeiToGwei(bal.PendingOut)
return &asset.Balance{
Available: dexeth.WeiToGwei(bal.Current) - locked,
Expand Down Expand Up @@ -672,10 +674,75 @@ func (eth *ExchangeWallet) Swap(swaps *asset.Swaps) ([]asset.Receipt, asset.Coin

// Redeem sends the redemption transaction, which may contain more than one
// redemption.
func (*ExchangeWallet) Redeem(form *asset.RedeemForm) ([]dex.Bytes, asset.Coin, uint64, error) {
func (eth *ExchangeWallet) Redeem(form *asset.RedeemForm) ([]dex.Bytes, asset.Coin, uint64, error) {

var unlocked uint64
for _, r := range form.Redemptions {
unlocked += r.UnlockedReserves
}
eth.lockedFundsMtx.Lock()
defer eth.lockedFundsMtx.Unlock()

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

return nil, nil, 0, asset.ErrNotImplemented
}

// 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.lockedFundsMtx.Lock()
bal, err := eth.balance()
if err != nil {
return 0, fmt.Errorf("error retreiving balance: %w", err)
}
if reserve > bal.Available {
err = fmt.Errorf("balance too low. %d < %d", bal.Available, reserve)
} else {
eth.redemptionReserve += reserve
}
eth.lockedFundsMtx.Unlock()

return reserve, err
}

// UnlockReserves unlocks the specified amount from redemption reserves. Part
// of the AccountRedeemer interface.
func (eth *ExchangeWallet) UnlockReserves(reserves uint64) {
eth.lockedFundsMtx.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.lockedFundsMtx.Unlock()
}

// ReReserves checks out an amount for redemptions. Use ReReserves after
// initializing a new ExchangeWallet.
func (eth *ExchangeWallet) ReReserve(req uint64) error {
eth.lockedFundsMtx.Lock()
defer eth.lockedFundsMtx.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 @@ -685,16 +752,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 := secp256k1.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
72 changes: 68 additions & 4 deletions client/asset/eth/eth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -155,16 +155,21 @@ 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) sendToAddr(ctx context.Context, addr common.Address, val uint64) (*types.Transaction, error) {
Expand Down Expand Up @@ -1493,7 +1498,66 @@ func TestSwapConfirmation(t *testing.T) {
state.BlockHeight = 6
state.State = dexeth.SSRedeemed
checkResult(false, 1, true)
}

func TestRedemptionReserves(t *testing.T) {
node := newTestNode(nil)
node.bal = newBalance(1e9, 0, 0, 0)
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 one of each.
eth.Redeem(&asset.RedeemForm{
Redemptions: []*asset.Redemption{{
UnlockedReserves: lockPerV0,
}, {
UnlockedReserves: lockPerV1,
}},
})

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

func ethToGwei(v uint64) uint64 {
Expand Down
38 changes: 18 additions & 20 deletions client/asset/eth/nodeclient.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ import (
"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/crypto/secp256k1"
"github.com/ethereum/go-ethereum/eth/ethconfig"
"github.com/ethereum/go-ethereum/ethclient"
"github.com/ethereum/go-ethereum/les"
Expand Down Expand Up @@ -341,36 +343,32 @@ func (n *nodeClient) swap(ctx context.Context, secretHash [32]byte, contractVer
})
}

// wallet returns a wallet that owns acct from an ethereum wallet.
func (n *nodeClient) wallet(acct accounts.Account) (accounts.Wallet, error) {
wallet, err := n.node.AccountManager().Find(acct)
// signData uses the private key of the address to sign a piece of 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, fmt.Errorf("error finding wallet for account %s: %w", acct.Address, err)
return nil, nil, err
}
if len(sig) != 65 {
return nil, nil, fmt.Errorf("unexpected signature length %d", len(sig))
}
return wallet, nil
}

// 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) {
account := accounts.Account{Address: addr}
wallet, err := n.wallet(account)
pubKey, err = secp256k1.RecoverPubkey(crypto.Keccak256(data), sig)
if err != nil {
return nil, err
return nil, nil, fmt.Errorf("SignMessage: error recovering pubkey %w", err)
}

// The mime type argument to SignData is not used in the keystore wallet in geth.
// It treats any data like plain text.
return wallet.SignData(account, accounts.MimetypeTextPlain, data)
// 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 {
wallet, err := n.wallet(accounts.Account{Address: txOpts.From})
if err != nil {
return err
}
txOpts.Signer = func(addr common.Address, tx *types.Transaction) (*types.Transaction, error) {
return wallet.SignTx(accounts.Account{Address: addr}, tx, n.chainID)
return n.creds.wallet.SignTx(*n.creds.acct, tx, n.chainID)
}
return nil
}
Expand Down
9 changes: 3 additions & 6 deletions client/asset/eth/nodeclient_harness_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1336,14 +1336,11 @@ 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 := secp256k1.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 @@ -1353,7 +1350,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 !secp256k1.VerifySignature(pubKey, crypto.Keccak256(msg), signature[:len(signature)-1]) {
if !secp256k1.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 @@ -322,6 +336,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 bf2d28e

Please sign in to comment.