mirror of
https://github.com/AthanorLabs/atomic-swap.git
synced 2026-01-09 14:18:03 -05:00
491 lines
14 KiB
Go
491 lines
14 KiB
Go
package bob
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/ecdsa"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"math/big"
|
|
"sync"
|
|
"time"
|
|
|
|
eth "github.com/ethereum/go-ethereum"
|
|
"github.com/ethereum/go-ethereum/accounts/abi/bind"
|
|
ethcommon "github.com/ethereum/go-ethereum/common"
|
|
ethcrypto "github.com/ethereum/go-ethereum/crypto"
|
|
"github.com/fatih/color" //nolint:misspell
|
|
|
|
"github.com/noot/atomic-swap/common"
|
|
"github.com/noot/atomic-swap/common/types"
|
|
mcrypto "github.com/noot/atomic-swap/crypto/monero"
|
|
"github.com/noot/atomic-swap/crypto/secp256k1"
|
|
"github.com/noot/atomic-swap/dleq"
|
|
"github.com/noot/atomic-swap/monero"
|
|
"github.com/noot/atomic-swap/net"
|
|
"github.com/noot/atomic-swap/net/message"
|
|
pcommon "github.com/noot/atomic-swap/protocol"
|
|
pswap "github.com/noot/atomic-swap/protocol/swap"
|
|
"github.com/noot/atomic-swap/swapfactory"
|
|
)
|
|
|
|
var (
|
|
// this is from the autogenerated swap.go
|
|
// TODO: generate this ourselves instead of hard-coding
|
|
refundedTopic = ethcommon.HexToHash("0x4fd30f3ee0d64f7eaa62d0e005ca64c6a560652156d6c33f23ea8ca4936106e0")
|
|
)
|
|
|
|
type swapState struct {
|
|
bob *Instance
|
|
ctx context.Context
|
|
cancel context.CancelFunc
|
|
sync.Mutex
|
|
infofile string
|
|
|
|
info *pswap.Info
|
|
offer *types.Offer
|
|
statusCh chan types.Status
|
|
|
|
// our keys for this session
|
|
dleqProof *dleq.Proof
|
|
secp256k1Pub *secp256k1.PublicKey
|
|
privkeys *mcrypto.PrivateKeyPair
|
|
pubkeys *mcrypto.PublicKeyPair
|
|
|
|
// swap contract and timeouts in it; set once contract is deployed
|
|
contract *swapfactory.SwapFactory
|
|
contractSwapID *big.Int
|
|
contractAddr ethcommon.Address
|
|
t0, t1 time.Time
|
|
txOpts *bind.TransactOpts
|
|
|
|
// Alice's keys for this session
|
|
alicePublicKeys *mcrypto.PublicKeyPair
|
|
aliceSecp256K1PublicKey *secp256k1.PublicKey
|
|
|
|
// next expected network message
|
|
nextExpectedMessage net.Message
|
|
|
|
// channels
|
|
readyCh chan struct{}
|
|
|
|
// address of reclaimed monero wallet, if the swap is refunded77
|
|
moneroReclaimAddress mcrypto.Address
|
|
}
|
|
|
|
func newSwapState(b *Instance, offer *types.Offer, statusCh chan types.Status, infofile string,
|
|
providesAmount common.MoneroAmount, desiredAmount common.EtherAmount) (*swapState, error) {
|
|
txOpts, err := bind.NewKeyedTransactorWithChainID(b.ethPrivKey, b.chainID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
txOpts.GasPrice = b.gasPrice
|
|
txOpts.GasLimit = b.gasLimit
|
|
|
|
exchangeRate := types.ExchangeRate(providesAmount.AsMonero() / desiredAmount.AsEther())
|
|
stage := types.ExpectingKeys
|
|
if statusCh == nil {
|
|
statusCh = make(chan types.Status, 7)
|
|
}
|
|
statusCh <- stage
|
|
info := pswap.NewInfo(types.ProvidesXMR, providesAmount.AsMonero(), desiredAmount.AsEther(),
|
|
exchangeRate, stage, statusCh)
|
|
if err := b.swapManager.AddSwap(info); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(b.ctx)
|
|
s := &swapState{
|
|
ctx: ctx,
|
|
cancel: cancel,
|
|
bob: b,
|
|
offer: offer,
|
|
infofile: infofile,
|
|
nextExpectedMessage: &net.SendKeysMessage{},
|
|
readyCh: make(chan struct{}),
|
|
txOpts: txOpts,
|
|
info: info,
|
|
statusCh: statusCh,
|
|
}
|
|
|
|
if err := pcommon.WriteSwapIDToFile(infofile, info.ID()); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return s, nil
|
|
}
|
|
|
|
// SendKeysMessage ...
|
|
func (s *swapState) SendKeysMessage() (*net.SendKeysMessage, error) {
|
|
if err := s.generateAndSetKeys(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &net.SendKeysMessage{
|
|
ProvidedAmount: s.info.ProvidedAmount(),
|
|
PublicSpendKey: s.pubkeys.SpendKey().Hex(),
|
|
PrivateViewKey: s.privkeys.ViewKey().Hex(),
|
|
DLEqProof: hex.EncodeToString(s.dleqProof.Proof()),
|
|
Secp256k1PublicKey: s.secp256k1Pub.String(),
|
|
EthAddress: s.bob.ethAddress.String(),
|
|
}, nil
|
|
}
|
|
|
|
// InfoFile returns the swap's infofile path
|
|
func (s *swapState) InfoFile() string {
|
|
return s.infofile
|
|
}
|
|
|
|
// ReceivedAmount returns the amount received, or expected to be received, at the end of the swap
|
|
func (s *swapState) ReceivedAmount() float64 {
|
|
return s.info.ReceivedAmount()
|
|
}
|
|
|
|
// ID returns the ID of the swap
|
|
func (s *swapState) ID() uint64 {
|
|
return s.info.ID()
|
|
}
|
|
|
|
// ProtocolExited is called by the network when the protocol stream closes.
|
|
// If it closes prematurely, we need to perform recovery.
|
|
func (s *swapState) ProtocolExited() error {
|
|
s.Lock()
|
|
defer s.Unlock()
|
|
|
|
defer func() {
|
|
// stop all running goroutines
|
|
s.cancel()
|
|
s.bob.swapState = nil
|
|
s.bob.swapManager.CompleteOngoingSwap()
|
|
|
|
if s.info.Status() != types.CompletedSuccess {
|
|
// re-add offer, as it wasn't taken successfully
|
|
s.bob.offerManager.putOffer(s.offer)
|
|
}
|
|
}()
|
|
|
|
if s.info.Status() == types.CompletedSuccess {
|
|
str := color.New(color.Bold).Sprintf("**swap completed successfully: id=%d**", s.ID())
|
|
log.Info(str)
|
|
return nil
|
|
}
|
|
|
|
if s.info.Status() == types.CompletedRefund {
|
|
str := color.New(color.Bold).Sprintf("**swap refunded successfully: id=%d**", s.ID())
|
|
log.Info(str)
|
|
return nil
|
|
}
|
|
|
|
switch s.nextExpectedMessage.(type) {
|
|
case *net.SendKeysMessage:
|
|
// we are fine, as we only just initiated the protocol.
|
|
s.clearNextExpectedMessage(types.CompletedAbort)
|
|
return errSwapAborted
|
|
case *message.NotifyContractDeployed:
|
|
// we were waiting for the contract to be deployed, but haven't
|
|
// locked out funds yet, so we're fine.
|
|
s.clearNextExpectedMessage(types.CompletedAbort)
|
|
return errSwapAborted
|
|
case *message.NotifyReady:
|
|
// we already locked our funds - need to wait until we can claim
|
|
// the funds (ie. wait until after t0)
|
|
txHash, err := s.tryClaim()
|
|
if err != nil {
|
|
log.Errorf("failed to claim funds: err=%s", err)
|
|
} else {
|
|
log.Infof("claimed ether! transaction hash=%s", txHash)
|
|
return nil
|
|
}
|
|
|
|
// we should check if Alice refunded, if so then check contract for secret
|
|
address, err := s.tryReclaimMonero()
|
|
if err != nil {
|
|
log.Errorf("failed to check for refund: err=%s", err)
|
|
// TODO: keep retrying until success
|
|
return err
|
|
}
|
|
|
|
s.clearNextExpectedMessage(types.CompletedRefund)
|
|
s.moneroReclaimAddress = address
|
|
log.Infof("regained private key to monero wallet, address=%s", address)
|
|
return nil
|
|
default:
|
|
s.clearNextExpectedMessage(types.CompletedAbort)
|
|
log.Errorf("unexpected nextExpectedMessage in ProtocolExited: type=%T", s.nextExpectedMessage)
|
|
return errUnexpectedMessageType
|
|
}
|
|
}
|
|
|
|
func (s *swapState) tryReclaimMonero() (mcrypto.Address, error) {
|
|
skA, err := s.filterForRefund()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return s.reclaimMonero(skA)
|
|
}
|
|
|
|
func (s *swapState) reclaimMonero(skA *mcrypto.PrivateSpendKey) (mcrypto.Address, error) {
|
|
vkA, err := skA.View()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
skAB := mcrypto.SumPrivateSpendKeys(skA, s.privkeys.SpendKey())
|
|
vkAB := mcrypto.SumPrivateViewKeys(vkA, s.privkeys.ViewKey())
|
|
kpAB := mcrypto.NewPrivateKeyPair(skAB, vkAB)
|
|
|
|
// write keys to file in case something goes wrong
|
|
if err = pcommon.WriteSharedSwapKeyPairToFile(s.infofile, kpAB, s.bob.env); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
// TODO: check balance
|
|
return monero.CreateMoneroWallet("bob-swap-wallet", s.bob.env, s.bob.client, kpAB)
|
|
}
|
|
|
|
func (s *swapState) filterForRefund() (*mcrypto.PrivateSpendKey, error) {
|
|
logs, err := s.bob.ethClient.FilterLogs(s.ctx, eth.FilterQuery{
|
|
Addresses: []ethcommon.Address{s.contractAddr},
|
|
Topics: [][]ethcommon.Hash{{refundedTopic}},
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to filter logs: %w", err)
|
|
}
|
|
|
|
if len(logs) == 0 {
|
|
return nil, errNoRefundLogsFound
|
|
}
|
|
|
|
sa, err := swapfactory.GetSecretFromLog(&logs[0], "Refunded")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get secret from log: %w", err)
|
|
}
|
|
|
|
return sa, nil
|
|
}
|
|
|
|
func (s *swapState) tryClaim() (ethcommon.Hash, error) {
|
|
untilT0 := time.Until(s.t0)
|
|
untilT1 := time.Until(s.t1)
|
|
info, err := s.contract.Swaps(s.bob.callOpts, s.contractSwapID)
|
|
if err != nil {
|
|
return ethcommon.Hash{}, err
|
|
}
|
|
|
|
if untilT0 > 0 && !info.IsReady {
|
|
// we need to wait until t0 to claim
|
|
log.Infof("waiting until time %s to claim, time now=%s", s.t0, time.Now())
|
|
<-time.After(untilT0 + time.Second)
|
|
}
|
|
|
|
if untilT1 < 0 {
|
|
// we've passed t1, our only option now is for Alice to refund
|
|
// and we can regain control of the locked XMR.
|
|
return ethcommon.Hash{}, errPastClaimTime
|
|
}
|
|
|
|
return s.claimFunds()
|
|
}
|
|
|
|
// generateKeys generates Bob's spend and view keys (s_b, v_b)
|
|
// It returns Bob's public spend key and his private view key, so that Alice can see
|
|
// if the funds are locked.
|
|
func (s *swapState) generateAndSetKeys() error {
|
|
if s.privkeys != nil {
|
|
return nil
|
|
}
|
|
|
|
keysAndProof, err := generateKeys()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
s.dleqProof = keysAndProof.DLEqProof
|
|
s.secp256k1Pub = keysAndProof.Secp256k1PublicKey
|
|
s.privkeys = keysAndProof.PrivateKeyPair
|
|
s.pubkeys = keysAndProof.PublicKeyPair
|
|
|
|
return pcommon.WriteKeysToFile(s.infofile, s.privkeys, s.bob.env)
|
|
}
|
|
|
|
func generateKeys() (*pcommon.KeysAndProof, error) {
|
|
return pcommon.GenerateKeysAndProof()
|
|
}
|
|
|
|
// getSecret secrets returns the current secret scalar used to unlock funds from the contract.
|
|
func (s *swapState) getSecret() [32]byte {
|
|
secret := s.dleqProof.Secret()
|
|
var sc [32]byte
|
|
copy(sc[:], common.Reverse(secret[:]))
|
|
return sc
|
|
}
|
|
|
|
// setAlicePublicKeys sets Alice's public spend and view keys
|
|
func (s *swapState) setAlicePublicKeys(sk *mcrypto.PublicKeyPair, secp256k1Pub *secp256k1.PublicKey) {
|
|
s.alicePublicKeys = sk
|
|
s.aliceSecp256K1PublicKey = secp256k1Pub
|
|
}
|
|
|
|
// setContract sets the contract in which Alice has locked her ETH.
|
|
func (s *swapState) setContract(address ethcommon.Address) error {
|
|
var err error
|
|
s.contractAddr = address
|
|
s.contract, err = swapfactory.NewSwapFactory(address, s.bob.ethClient)
|
|
return err
|
|
}
|
|
|
|
func (s *swapState) setTimeouts() error {
|
|
info, err := s.contract.Swaps(s.bob.callOpts, s.contractSwapID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get swap info from contract: err=%w", err)
|
|
}
|
|
|
|
s.t0 = time.Unix(info.Timeout0.Int64(), 0)
|
|
s.t1 = time.Unix(info.Timeout1.Int64(), 0)
|
|
return nil
|
|
}
|
|
|
|
// checkContract checks the contract's balance and Claim/Refund keys.
|
|
// if the balance doesn't match what we're expecting to receive, or the public keys in the contract
|
|
// aren't what we expect, we error and abort the swap.
|
|
func (s *swapState) checkContract() error {
|
|
newTopic := ethcommon.HexToHash("0x982a99d883f17ecd5797205d5b3674205d7882bb28a9487d736d3799422cd055")
|
|
logs, err := s.bob.ethClient.FilterLogs(s.ctx, eth.FilterQuery{
|
|
Addresses: []ethcommon.Address{s.contractAddr},
|
|
Topics: [][]ethcommon.Hash{{newTopic}},
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("failed to filter logs: %w", err)
|
|
}
|
|
|
|
if len(logs) == 0 {
|
|
return errors.New("cannot find New log")
|
|
}
|
|
|
|
// search for log pertaining to our swap ID
|
|
var event *swapfactory.SwapFactoryNew
|
|
for i := len(logs) - 1; i >= 0; i-- {
|
|
newEvent, err := s.contract.ParseNew(logs[i]) //nolint:govet
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if newEvent.SwapID.Cmp(s.contractSwapID) == 0 {
|
|
event = newEvent
|
|
break
|
|
}
|
|
}
|
|
|
|
if event == nil {
|
|
return fmt.Errorf("failed to find New event with given swap ID %d", s.contractSwapID)
|
|
}
|
|
|
|
// check that contract was constructed with correct secp256k1 keys
|
|
skOurs := s.secp256k1Pub.Keccak256()
|
|
if !bytes.Equal(event.ClaimKey[:], skOurs[:]) {
|
|
return fmt.Errorf("contract claim key is not expected: got 0x%x, expected 0x%x", event.ClaimKey, skOurs)
|
|
}
|
|
|
|
skTheirs := s.aliceSecp256K1PublicKey.Keccak256()
|
|
if !bytes.Equal(event.RefundKey[:], skTheirs[:]) {
|
|
return fmt.Errorf("contract refund key is not expected: got 0x%x, expected 0x%x", event.RefundKey, skTheirs)
|
|
}
|
|
|
|
// check value of created swap
|
|
info, err := s.contract.Swaps(s.bob.callOpts, s.contractSwapID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
expected := common.EtherToWei(s.info.ReceivedAmount()).BigInt()
|
|
if info.Value.Cmp(expected) < 0 {
|
|
return fmt.Errorf("contract does not have expected balance: got %s, expected %s", info.Value, expected)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// lockFunds locks Bob's funds in the monero account specified by public key
|
|
// (S_a + S_b), viewable with (V_a + V_b)
|
|
// It accepts the amount to lock as the input
|
|
// TODO: units
|
|
func (s *swapState) lockFunds(amount common.MoneroAmount) (mcrypto.Address, error) {
|
|
kp := mcrypto.SumSpendAndViewKeys(s.alicePublicKeys, s.pubkeys)
|
|
log.Infof("going to lock XMR funds, amount(piconero)=%d", amount)
|
|
|
|
balance, err := s.bob.client.GetBalance(0)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
log.Debug("total XMR balance: ", balance.Balance)
|
|
log.Info("unlocked XMR balance: ", balance.UnlockedBalance)
|
|
|
|
address := kp.Address(s.bob.env)
|
|
txResp, err := s.bob.client.Transfer(address, 0, uint(amount))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
log.Infof("locked XMR, txHash=%s fee=%d", txResp.TxHash, txResp.Fee)
|
|
|
|
bobAddr, err := s.bob.client.GetAddress(0)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
// if we're on a development --regtest node, generate some blocks
|
|
if s.bob.env == common.Development {
|
|
_ = s.bob.daemonClient.GenerateBlocks(bobAddr.Address, 2)
|
|
} else {
|
|
// otherwise, wait for new blocks
|
|
if err := monero.WaitForBlocks(s.bob.client); err != nil {
|
|
return "", err
|
|
}
|
|
}
|
|
|
|
if err := s.bob.client.Refresh(); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
log.Infof("successfully locked XMR funds: address=%s", address)
|
|
return address, nil
|
|
}
|
|
|
|
// claimFunds redeems Bob's ETH funds by calling Claim() on the contract
|
|
func (s *swapState) claimFunds() (ethcommon.Hash, error) {
|
|
pub := s.bob.ethPrivKey.Public().(*ecdsa.PublicKey)
|
|
addr := ethcrypto.PubkeyToAddress(*pub)
|
|
|
|
balance, err := s.bob.ethClient.BalanceAt(s.ctx, addr, nil)
|
|
if err != nil {
|
|
return ethcommon.Hash{}, err
|
|
}
|
|
|
|
log.Info("Bob's balance before claim: ", balance)
|
|
|
|
// call swap.Swap.Claim() w/ b.privkeys.sk, revealing Bob's secret spend key
|
|
sc := s.getSecret()
|
|
tx, err := s.contract.Claim(s.txOpts, s.contractSwapID, sc)
|
|
if err != nil {
|
|
return ethcommon.Hash{}, err
|
|
}
|
|
|
|
log.Infof("sent Claim tx, tx hash=%s", tx.Hash())
|
|
|
|
if _, err = common.WaitForReceipt(s.ctx, s.bob.ethClient, tx.Hash()); err != nil {
|
|
return ethcommon.Hash{}, fmt.Errorf("failed to check claim transaction receipt: %w", err)
|
|
}
|
|
|
|
balance, err = s.bob.ethClient.BalanceAt(s.ctx, addr, nil)
|
|
if err != nil {
|
|
return ethcommon.Hash{}, err
|
|
}
|
|
|
|
log.Info("Bob's balance after claim: ", balance)
|
|
return tx.Hash(), nil
|
|
}
|