Skip to content

Commit 16a4814

Browse files
authored
Redemption prototype: Observe incoming proposals (#3639)
Refs #3609 Here is the first part of the client-side redemption support. In this PR, we are integrating with the recent changes introduced in the `WalletCoordinator` contract (keep-network/tbtc-v2#633) and we are adding the ability to observe incoming redemption proposals. Here is a brief description of particular changes. ### `WalletCoordinator` contract integration code We are adding all the boilerplate code required to see redemption proposals emitted by the `WalletCoordinator` contract. This work includes: - Updates of contract bindings - Integration with the `tbtc` package through the `tbtc.WalletCoordinatorChain` interface and `chain/ethereum` implementation - Implementation of tools allowing to convert between length-prefixed redeemer output scripts used by the smart contracts and the prefixless form preferred in the off-chain client ### Observe incoming proposals This part uses the code described in the previous section to: - Handle incoming redemption proposals submitted to the `WalletCoordinator` contract - De-duplicate incoming proposals - Confirm the finality of the incoming proposals
2 parents 2173b15 + 73bc4a5 commit 16a4814

18 files changed

+2970
-377
lines changed

pkg/bitcoin/bitcoin.go

+18
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@
77
// type should leak outside.
88
package bitcoin
99

10+
import (
11+
"bytes"
12+
"github.com/btcsuite/btcd/wire"
13+
)
14+
1015
// CompactSizeUint is a documentation type that is supposed to capture the
1116
// details of the Bitcoin's CompactSize Unsigned Integer. It represents a
1217
// number value encoded to bytes according to the following rules:
@@ -22,6 +27,19 @@ package bitcoin
2227
// https://developer.bitcoin.org/reference/transactions.html#compactsize-unsigned-integers
2328
type CompactSizeUint uint64
2429

30+
// readCompactSizeUint reads the leading CompactSizeUint from the provided
31+
// variable length data. Returns the value held by the CompactSizeUint as
32+
// the first argument and the byte length of the CompactSizeUint as the
33+
// second one.
34+
func readCompactSizeUint(varLenData []byte) (CompactSizeUint, int, error) {
35+
csu, err := wire.ReadVarInt(bytes.NewReader(varLenData), 0)
36+
if err != nil {
37+
return 0, 0, err
38+
}
39+
40+
return CompactSizeUint(csu), wire.VarIntSerializeSize(csu), nil
41+
}
42+
2543
// ByteOrder represents the byte order used by the Bitcoin byte arrays. The
2644
// Bitcoin ecosystem is not totally consistent in this regard and different
2745
// byte orders are used depending on the purpose.

pkg/bitcoin/bitcoin_test.go

+79
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
package bitcoin
2+
3+
import (
4+
"encoding/hex"
5+
"fmt"
6+
"github.com/keep-network/keep-core/internal/testutils"
7+
"reflect"
8+
"testing"
9+
)
10+
11+
func TestReadCompactSizeUint(t *testing.T) {
12+
fromHex := func(hexString string) []byte {
13+
bytes, err := hex.DecodeString(hexString)
14+
if err != nil {
15+
t.Fatal(err)
16+
}
17+
return bytes
18+
}
19+
20+
var tests = map[string]struct {
21+
data []byte
22+
expectedValue CompactSizeUint
23+
expectedByteLength int
24+
expectedErr error
25+
}{
26+
"1-byte compact size uint": {
27+
data: fromHex("bb"),
28+
expectedValue: 187,
29+
expectedByteLength: 1,
30+
},
31+
"3-byte compact size uint": {
32+
data: fromHex("fd0302"),
33+
expectedValue: 515,
34+
expectedByteLength: 3,
35+
},
36+
"5-byte compact size uint": {
37+
data: fromHex("fe703a0f00"),
38+
expectedValue: 998000,
39+
expectedByteLength: 5,
40+
},
41+
"9-byte compact size uint": {
42+
data: fromHex("ff57284e56dab40000"),
43+
expectedValue: 198849843832919,
44+
expectedByteLength: 9,
45+
},
46+
"malformed compact size uint": {
47+
data: fromHex("fd01"),
48+
expectedErr: fmt.Errorf("unexpected EOF"),
49+
},
50+
}
51+
52+
for testName, test := range tests {
53+
t.Run(testName, func(t *testing.T) {
54+
value, byteLength, err := readCompactSizeUint(test.data)
55+
56+
if !reflect.DeepEqual(test.expectedErr, err) {
57+
t.Errorf(
58+
"unexpected error\nexpected: %+v\nactual: %+v\n",
59+
test.expectedErr,
60+
err,
61+
)
62+
}
63+
64+
testutils.AssertIntsEqual(
65+
t,
66+
"value",
67+
int(test.expectedValue),
68+
int(value),
69+
)
70+
71+
testutils.AssertIntsEqual(
72+
t,
73+
"byte length",
74+
test.expectedByteLength,
75+
byteLength,
76+
)
77+
})
78+
}
79+
}

pkg/bitcoin/script.go

+30-7
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,36 @@ import (
44
"crypto/ecdsa"
55
"crypto/elliptic"
66
"crypto/sha256"
7+
"fmt"
78
"github.com/btcsuite/btcd/txscript"
89
"github.com/btcsuite/btcutil"
910
)
1011

12+
// Script represents an arbitrary Bitcoin script, NOT prepended with the
13+
// byte-length of the script
14+
type Script []byte
15+
16+
// NewScriptFromVarLenData construct a Script instance based on the provided
17+
// variable length data prepended with a CompactSizeUint.
18+
func NewScriptFromVarLenData(varLenData []byte) (Script, error) {
19+
// Extract the CompactSizeUint value that holds the byte length of the script.
20+
// Also, extract the byte length of the CompactSizeUint itself.
21+
scriptByteLength, compactByteLength, err := readCompactSizeUint(varLenData)
22+
if err != nil {
23+
return nil, fmt.Errorf("cannot read compact size uint: [%v]", err)
24+
}
25+
26+
// Make sure the combined byte length of the script and the byte length
27+
// of the CompactSizeUint matches the total byte length of the variable
28+
// length data. Otherwise, the input data slice is malformed.
29+
if uint64(scriptByteLength)+uint64(compactByteLength) != uint64(len(varLenData)) {
30+
return nil, fmt.Errorf("malformed var len data")
31+
}
32+
33+
// Extract the actual script by omitting the leading CompactSizeUint.
34+
return varLenData[compactByteLength:], nil
35+
}
36+
1137
// PublicKeyHash constructs the 20-byte public key hash by applying SHA-256
1238
// then RIPEMD-160 on the provided ECDSA public key.
1339
func PublicKeyHash(publicKey *ecdsa.PublicKey) [20]byte {
@@ -28,7 +54,7 @@ func PublicKeyHash(publicKey *ecdsa.PublicKey) [20]byte {
2854
// PayToWitnessPublicKeyHash constructs a P2WPKH script for the provided
2955
// 20-byte public key hash. The function assumes the provided public key hash
3056
// is valid.
31-
func PayToWitnessPublicKeyHash(publicKeyHash [20]byte) ([]byte, error) {
57+
func PayToWitnessPublicKeyHash(publicKeyHash [20]byte) (Script, error) {
3258
return txscript.NewScriptBuilder().
3359
AddOp(txscript.OP_0).
3460
AddData(publicKeyHash[:]).
@@ -37,7 +63,7 @@ func PayToWitnessPublicKeyHash(publicKeyHash [20]byte) ([]byte, error) {
3763

3864
// PayToPublicKeyHash constructs a P2PKH script for the provided 20-byte public
3965
// key hash. The function assumes the provided public key hash is valid.
40-
func PayToPublicKeyHash(publicKeyHash [20]byte) ([]byte, error) {
66+
func PayToPublicKeyHash(publicKeyHash [20]byte) (Script, error) {
4167
return txscript.NewScriptBuilder().
4268
AddOp(txscript.OP_DUP).
4369
AddOp(txscript.OP_HASH160).
@@ -47,9 +73,6 @@ func PayToPublicKeyHash(publicKeyHash [20]byte) ([]byte, error) {
4773
Script()
4874
}
4975

50-
// Script represents an arbitrary Bitcoin script.
51-
type Script []byte
52-
5376
// WitnessScriptHash constructs the 32-byte witness script hash by applying
5477
// single SHA-256 on the provided Script.
5578
func WitnessScriptHash(script Script) [32]byte {
@@ -69,7 +92,7 @@ func ScriptHash(script Script) [20]byte {
6992

7093
// PayToWitnessScriptHash constructs a P2WSH script for the provided 32-byte
7194
// witness script hash. The function assumes the provided script hash is valid.
72-
func PayToWitnessScriptHash(witnessScriptHash [32]byte) ([]byte, error) {
95+
func PayToWitnessScriptHash(witnessScriptHash [32]byte) (Script, error) {
7396
return txscript.NewScriptBuilder().
7497
AddOp(txscript.OP_0).
7598
AddData(witnessScriptHash[:]).
@@ -78,7 +101,7 @@ func PayToWitnessScriptHash(witnessScriptHash [32]byte) ([]byte, error) {
78101

79102
// PayToScriptHash constructs a P2SH script for the provided 20-byte script
80103
// hash. The function assumes the provided script hash is valid.
81-
func PayToScriptHash(scriptHash [20]byte) ([]byte, error) {
104+
func PayToScriptHash(scriptHash [20]byte) (Script, error) {
82105
return txscript.NewScriptBuilder().
83106
AddOp(txscript.OP_HASH160).
84107
AddData(scriptHash[:]).

pkg/bitcoin/script_test.go

+55
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,68 @@ import (
44
"crypto/ecdsa"
55
"crypto/elliptic"
66
"encoding/hex"
7+
"fmt"
8+
"reflect"
79
"testing"
810

911
"github.com/btcsuite/btcd/btcec"
1012

1113
"github.com/keep-network/keep-core/internal/testutils"
1214
)
1315

16+
func TestNewScriptFromVarLenData(t *testing.T) {
17+
fromHex := func(hexString string) []byte {
18+
bytes, err := hex.DecodeString(hexString)
19+
if err != nil {
20+
t.Fatal(err)
21+
}
22+
return bytes
23+
}
24+
25+
var tests = map[string]struct {
26+
data []byte
27+
expectedScript Script
28+
expectedErr error
29+
}{
30+
"proper variable length data": {
31+
data: fromHex("1600148db50eb52063ea9d98b3eac91489a90f738986f6"),
32+
expectedScript: fromHex("00148db50eb52063ea9d98b3eac91489a90f738986f6"),
33+
},
34+
"nil variable length data": {
35+
data: nil,
36+
expectedErr: fmt.Errorf("cannot read compact size uint: [EOF]"),
37+
},
38+
"variable length data with missing script": {
39+
data: fromHex("16"),
40+
expectedErr: fmt.Errorf("malformed var len data"),
41+
},
42+
"variable length data with missing compact size uint": {
43+
data: fromHex("00148db50eb52063ea9d98b3eac91489a90f738986f6"),
44+
expectedErr: fmt.Errorf("malformed var len data"),
45+
},
46+
"variable length data with wrong compact size uint value": {
47+
data: fromHex("1500148db50eb52063ea9d98b3eac91489a90f738986f6"),
48+
expectedErr: fmt.Errorf("malformed var len data"),
49+
},
50+
}
51+
52+
for testName, test := range tests {
53+
t.Run(testName, func(t *testing.T) {
54+
script, err := NewScriptFromVarLenData(test.data)
55+
56+
if !reflect.DeepEqual(test.expectedErr, err) {
57+
t.Errorf(
58+
"unexpected error\nexpected: %+v\nactual: %+v\n",
59+
test.expectedErr,
60+
err,
61+
)
62+
}
63+
64+
testutils.AssertBytesEqual(t, test.expectedScript, script)
65+
})
66+
}
67+
}
68+
1469
func TestPublicKeyHash(t *testing.T) {
1570
// An arbitrary uncompressed public key.
1671
publicKeyBytes, err := hex.DecodeString(

pkg/bitcoin/transaction.go

+4-3
Original file line numberDiff line numberDiff line change
@@ -226,9 +226,10 @@ type TransactionOutput struct {
226226
// Value denotes the number of satoshis to spend. Zero is a valid value.
227227
Value int64
228228
// PublicKeyScript defines the conditions that must be satisfied to spend
229-
// this output. This slice MUST NOT start with the byte-length of the script
230-
// encoded as CompactSizeUint as this is done during transaction serialization.
231-
PublicKeyScript []byte
229+
// this output. As stated in the Script docstring, this field MUST NOT
230+
// start with the byte-length of the script encoded as CompactSizeUint as
231+
// this is done during transaction serialization.
232+
PublicKeyScript Script
232233
}
233234

234235
// UnspentTransactionOutput represents an unspent output (UTXO) of a Bitcoin

pkg/chain/ethereum/tbtc.go

+56
Original file line numberDiff line numberDiff line change
@@ -1533,3 +1533,59 @@ func (tc *TbtcChain) SubmitDepositSweepProposalWithReimbursement(
15331533
func (tc *TbtcChain) GetDepositSweepMaxSize() (uint16, error) {
15341534
return tc.walletCoordinator.DepositSweepMaxSize()
15351535
}
1536+
1537+
func (tc *TbtcChain) OnRedemptionProposalSubmitted(
1538+
handler func(event *tbtc.RedemptionProposalSubmittedEvent),
1539+
) subscription.EventSubscription {
1540+
onEvent := func(
1541+
proposal tbtcabi.WalletCoordinatorRedemptionProposal,
1542+
coordinator common.Address,
1543+
blockNumber uint64,
1544+
) {
1545+
tbtcProposal, err := convertRedemptionProposalFromAbiType(proposal)
1546+
if err != nil {
1547+
logger.Errorf(
1548+
"unexpected proposal in RedemptionProposalSubmitted event: [%v]",
1549+
err,
1550+
)
1551+
return
1552+
}
1553+
1554+
handler(&tbtc.RedemptionProposalSubmittedEvent{
1555+
Proposal: tbtcProposal,
1556+
Coordinator: chain.Address(coordinator.Hex()),
1557+
BlockNumber: blockNumber,
1558+
})
1559+
}
1560+
1561+
return tc.walletCoordinator.
1562+
RedemptionProposalSubmittedEvent(nil, nil).
1563+
OnEvent(onEvent)
1564+
}
1565+
1566+
func convertRedemptionProposalFromAbiType(
1567+
proposal tbtcabi.WalletCoordinatorRedemptionProposal,
1568+
) (*tbtc.RedemptionProposal, error) {
1569+
redeemersOutputScripts := make(
1570+
[]bitcoin.Script,
1571+
len(proposal.RedeemersOutputScripts),
1572+
)
1573+
1574+
for i, script := range proposal.RedeemersOutputScripts {
1575+
// The on-chain script representation is prepended with the script's
1576+
// byte-length while bitcoin.Script is not. We need to remove the
1577+
// length prefix.
1578+
parsedScript, err := bitcoin.NewScriptFromVarLenData(script)
1579+
if err != nil {
1580+
return nil, fmt.Errorf("cannot parse redeemer output script: [%v]", err)
1581+
}
1582+
1583+
redeemersOutputScripts[i] = parsedScript
1584+
}
1585+
1586+
return &tbtc.RedemptionProposal{
1587+
WalletPublicKeyHash: proposal.WalletPubKeyHash,
1588+
RedeemersOutputScripts: redeemersOutputScripts,
1589+
RedemptionTxFee: proposal.RedemptionTxFee,
1590+
}, nil
1591+
}

0 commit comments

Comments
 (0)