diff --git a/pkg/internal/tbtctest/marshaling.go b/pkg/internal/tbtctest/marshaling.go index f7683d1835..277f9afc6c 100644 --- a/pkg/internal/tbtctest/marshaling.go +++ b/pkg/internal/tbtctest/marshaling.go @@ -5,10 +5,12 @@ import ( "crypto/elliptic" "encoding/hex" "encoding/json" + "math/big" + "time" + "github.com/keep-network/keep-core/pkg/bitcoin" "github.com/keep-network/keep-core/pkg/chain" "github.com/keep-network/keep-core/pkg/tecdsa" - "math/big" ) // UnmarshalJSON implements a custom JSON unmarshaling logic to produce a @@ -148,6 +150,120 @@ func (dsts *DepositSweepTestScenario) UnmarshalJSON(data []byte) error { return nil } +// UnmarshalJSON implements a custom JSON unmarshaling logic to produce a +// proper RedemptionTestScenario. +func (rts *RedemptionTestScenario) UnmarshalJSON(data []byte) error { + type redemptionTestScenario struct { + Title string + WalletPublicKey string + WalletPrivateKey string + WalletMainUtxo *utxo + RedemptionRequests []struct { + Redeemer string + RedeemerOutputScript string + RequestedAmount uint64 + TreasuryFee uint64 + TxMaxFee uint64 + RequestedAt int64 + } + InputTransaction string + FeeShares []int64 + Signature signature + ExpectedSigHash string + ExpectedRedemptionTransaction string + ExpectedRedemptionTransactionHash string + ExpectedRedemptionTransactionWitnessHash string + } + + var unmarshaled redemptionTestScenario + + err := json.Unmarshal(data, &unmarshaled) + if err != nil { + return err + } + + // Unmarshal title. + rts.Title = unmarshaled.Title + + // Unmarshal wallet public key. + x, y := elliptic.Unmarshal( + tecdsa.Curve, + hexToSlice(unmarshaled.WalletPublicKey), + ) + rts.WalletPublicKey = &ecdsa.PublicKey{ + Curve: tecdsa.Curve, + X: x, + Y: y, + } + + // Unmarshal wallet private key. + rts.WalletPrivateKey = new(big.Int).SetBytes( + hexToSlice(unmarshaled.WalletPrivateKey), + ) + + // Unmarshal wallet main UTXO. + rts.WalletMainUtxo = unmarshaled.WalletMainUtxo.convert() + + // Unmarshal redemption requests. + for _, request := range unmarshaled.RedemptionRequests { + r := new(RedemptionRequest) + + r.Redeemer = chain.Address(request.Redeemer) + r.RedeemerOutputScript = hexToSlice(request.RedeemerOutputScript) + r.RequestedAmount = request.RequestedAmount + r.TreasuryFee = request.TreasuryFee + r.TxMaxFee = request.TxMaxFee + r.RequestedAt = time.Unix(request.RequestedAt, 0) + + rts.RedemptionRequests = append(rts.RedemptionRequests, r) + } + + // Unmarshal input transaction. + rts.InputTransaction = new(bitcoin.Transaction) + err = rts.InputTransaction.Deserialize(hexToSlice(unmarshaled.InputTransaction)) + if err != nil { + return err + } + + // Unmarshal fee shares. + rts.FeeShares = append(rts.FeeShares, unmarshaled.FeeShares...) + + // Unmarshal signature. + rts.Signature = unmarshaled.Signature.convert(rts.WalletPublicKey) + + // Unmarshal expected signature hash. + rts.ExpectedSigHash = new(big.Int).SetBytes(hexToSlice(unmarshaled.ExpectedSigHash)) + + // Unmarshal expected redemption transaction. + rts.ExpectedRedemptionTransaction = new(bitcoin.Transaction) + err = rts.ExpectedRedemptionTransaction.Deserialize( + hexToSlice(unmarshaled.ExpectedRedemptionTransaction), + ) + if err != nil { + return err + } + + // Unmarshal expected redemption transaction hash. + rts.ExpectedRedemptionTransactionHash, err = bitcoin.NewHashFromString( + unmarshaled.ExpectedRedemptionTransactionHash, + bitcoin.ReversedByteOrder, + ) + if err != nil { + return err + } + + // Unmarshal expected redemption transaction witness hash. + rts.ExpectedRedemptionTransactionWitnessHash, err = bitcoin.NewHashFromString( + unmarshaled.ExpectedRedemptionTransactionWitnessHash, + bitcoin.ReversedByteOrder, + ) + if err != nil { + return err + } + + return nil +} + // utxo is a helper type used for unmarshal UTXO encoded as JSON. type utxo struct { Outpoint struct { diff --git a/pkg/internal/tbtctest/tbtctest.go b/pkg/internal/tbtctest/tbtctest.go index e999917904..6554b904d0 100644 --- a/pkg/internal/tbtctest/tbtctest.go +++ b/pkg/internal/tbtctest/tbtctest.go @@ -20,6 +20,37 @@ // one input (a P2WSH deposit) was swept into a P2WPKH main UTXO. // For reference see: // https://live.blockcypher.com/btc-testnet/tx/9efc9d555233e12e06378a35a7b988d54f7043b5c3156adc79c7af0a0fd6f1a0 +// +// - redemption_scenario_0.json: Bitcoin redemption transaction that uses a +// single P2WPKH input to pay a single P2PKH redeemer script and a P2WPKH change. +// For reference see: +// https://live.blockcypher.com/btc-testnet/tx/c437f1117db977682334b53a71fbe63a42aab42f6e0976c35b69977f86308c20 +// +// - redemption_scenario_1.json: Bitcoin redemption transaction that uses a +// single P2WPKH input to pay a single P2WPKH redeemer script and a P2WPKH change. +// For reference see: +// https://live.blockcypher.com/btc-testnet/tx/925e61dc31396e7f2cbcc8bc9b4009b4f24ba679257762df078b7e9b875ea110 +// +// - redemption_scenario_2.json: Bitcoin redemption transaction that uses a +// single P2WPKH input to pay a single P2SH redeemer script and a P2WPKH change. +// For reference see: +// https://live.blockcypher.com/btc-testnet/tx/ef25c9c8f4df673def035c0c1880278c90030b3c94a56668109001a591c2c521 +// +// - redemption_scenario_3.json: Bitcoin redemption transaction that uses a +// single P2WPKH input to pay a single P2WSH redeemer script and a P2WPKH change. +// For reference see: +// https://live.blockcypher.com/btc-testnet/tx/3d28bb5bf73379da51bc683f4d0ed31d7b024466c619d80ebd9378077d900be3 +// +// - redemption_scenario_4.json: Bitcoin redemption transaction that uses a +// single P2WPKH input to pay redeemer scripts (P2PKH, P2WPKH, P2SH and P2WSH) +// and a P2WPKH change. +// For reference see: +// https://live.blockcypher.com/btc-testnet/tx/f70ff89fd2b6226183e4b8143cc5f0f457f05dd1dca0c6151ab66f4523d972b7 +// +// - redemption_scenario_5.json: Bitcoin redemption transaction that uses a +// single P2WPKH input to pay redeemer scripts (P2PKH, P2WPKH) without a change. +// For reference see: +// https://live.blockcypher.com/btc-testnet/tx/afcdf8f91273b73abc40018873978c22bbb7c3d8d669ef2faffa0c4b0898c8eb package tbtctest import ( @@ -34,11 +65,13 @@ import ( "path/filepath" "runtime" "strings" + "time" ) const ( testDataDirFormat = "%s/testdata" depositSweepTestDataFilePrefix = "deposit_sweep_scenario" + redemptionTestDataFilePrefix = "redemption_scenario" ) // Deposit holds the deposit data in the given test scenario. @@ -71,7 +104,43 @@ type DepositSweepTestScenario struct { // LoadDepositSweepTestScenarios loads all scenarios related with deposit sweep. func LoadDepositSweepTestScenarios() ([]*DepositSweepTestScenario, error) { - filePaths, err := detectTestDataFiles(depositSweepTestDataFilePrefix) + return loadTestScenarios[*DepositSweepTestScenario](depositSweepTestDataFilePrefix) +} + +// RedemptionRequest holds the redemption request data in the given test scenario. +type RedemptionRequest struct { + Redeemer chain.Address + RedeemerOutputScript []byte + RequestedAmount uint64 + TreasuryFee uint64 + TxMaxFee uint64 + RequestedAt time.Time +} + +// RedemptionTestScenario represents a redemption test scenario. +type RedemptionTestScenario struct { + Title string + WalletPublicKey *ecdsa.PublicKey + WalletPrivateKey *big.Int + WalletMainUtxo *bitcoin.UnspentTransactionOutput + RedemptionRequests []*RedemptionRequest + InputTransaction *bitcoin.Transaction + FeeShares []int64 + Signature *bitcoin.SignatureContainer + + ExpectedSigHash *big.Int + ExpectedRedemptionTransaction *bitcoin.Transaction + ExpectedRedemptionTransactionHash bitcoin.Hash + ExpectedRedemptionTransactionWitnessHash bitcoin.Hash +} + +// LoadRedemptionTestScenarios loads all scenarios related with redemption. +func LoadRedemptionTestScenarios() ([]*RedemptionTestScenario, error) { + return loadTestScenarios[*RedemptionTestScenario](redemptionTestDataFilePrefix) +} + +func loadTestScenarios[T json.Unmarshaler](testDataFilePrefix string) ([]T, error) { + filePaths, err := detectTestDataFiles(testDataFilePrefix) if err != nil { return nil, fmt.Errorf( "cannot detect test data files: [%v]", @@ -79,7 +148,7 @@ func LoadDepositSweepTestScenarios() ([]*DepositSweepTestScenario, error) { ) } - scenarios := make([]*DepositSweepTestScenario, 0) + scenarios := make([]T, 0) for _, filePath := range filePaths { // #nosec G304 (file path provided as taint input) @@ -94,7 +163,7 @@ func LoadDepositSweepTestScenarios() ([]*DepositSweepTestScenario, error) { ) } - var scenario DepositSweepTestScenario + var scenario T if err = json.Unmarshal(fileBytes, &scenario); err != nil { return nil, fmt.Errorf( "cannot unmarshal scenario for file [%v]: [%v]", @@ -103,7 +172,7 @@ func LoadDepositSweepTestScenarios() ([]*DepositSweepTestScenario, error) { ) } - scenarios = append(scenarios, &scenario) + scenarios = append(scenarios, scenario) } return scenarios, nil diff --git a/pkg/internal/tbtctest/testdata/redemption_scenario_0.json b/pkg/internal/tbtctest/testdata/redemption_scenario_0.json new file mode 100644 index 0000000000..0014dd4d1c --- /dev/null +++ b/pkg/internal/tbtctest/testdata/redemption_scenario_0.json @@ -0,0 +1,32 @@ +{ + "Title": "single non-witness public key hash redemption with witness change", + "WalletPublicKey": "04989d253b17a6a0f41838b84ff0d20e8898f9d7b1a98f2564da4cc29dcf8581d9d218b65e7d91c752f7b22eaceb771a9af3a6f3d3f010a5d471a1aeef7d7713af", + "WalletPrivateKey": "7c246a5d2fcf476fd6f805cb8174b1cf441b13ea414e5560ca2bdc963aeb7d0c", + "WalletMainUtxo": { + "Outpoint": { + "TransactionHash": "523e4bfb71804e5ed3b76c8933d733339563e560311c1bf835934ee7aae5db20", + "OutputIndex": 1 + }, + "Value": 1481680 + }, + "RedemptionRequests": [ + { + "Redeemer": "82883a4c7a8dd73ef165deb402d432613615ced4", + "RedeemerOutputScript": "76a9144130879211c54df460e484ddf9aac009cb38ee7488ac", + "RequestedAmount": 10000, + "TreasuryFee": 1000, + "TxMaxFee": 1600, + "RequestedAt": 1650623240 + } + ], + "InputTransaction": "0100000000010160d264b34e51e6567254bcaf4cc67e1e069483f4249dc50784eae682645fd11d0100000000ffffffff02d84000000000000022002086a303cdd2e2eab1d1679f1a813835dc5a1b65321077cdccaf08f98cbf04ca96d09b1600000000001600148db50eb52063ea9d98b3eac91489a90f738986f602483045022100ed5fa06ea5e9d4a9f0cf0df86a2cd473f693e5bda3d808ba82b04ee26d72b73f0220648f4d7bb25be781922349d382cf0f32ffcbbf89c483776472c2d15644a48d67012103989d253b17a6a0f41838b84ff0d20e8898f9d7b1a98f2564da4cc29dcf8581d900000000", + "FeeShares": [1600], + "Signature": { + "R": "e1bcecbf3c6fc9a4ce2fc8029264d98a1bef4ff3d590816532097fbb93b7fdfb", + "S": "6bca6c7af1db4c70d4d2c819eeb4c8430a291f5fe874c73c8f44acdd06c25d33" + }, + "ExpectedSigHash": "d1a6e27780b22b6d266f0a73f4cf6a7c67f00dce65b59c8508afad1aadda2489", + "ExpectedRedemptionTransaction": "0100000000010120dbe5aae74e9335f81b1c3160e563953333d733896cb7d35e4e8071fb4b3e520100000000ffffffff02e81c0000000000001976a9144130879211c54df460e484ddf9aac009cb38ee7488aca8781600000000001600148db50eb52063ea9d98b3eac91489a90f738986f602483045022100e1bcecbf3c6fc9a4ce2fc8029264d98a1bef4ff3d590816532097fbb93b7fdfb02206bca6c7af1db4c70d4d2c819eeb4c8430a291f5fe874c73c8f44acdd06c25d33012103989d253b17a6a0f41838b84ff0d20e8898f9d7b1a98f2564da4cc29dcf8581d900000000", + "ExpectedRedemptionTransactionHash": "c437f1117db977682334b53a71fbe63a42aab42f6e0976c35b69977f86308c20", + "ExpectedRedemptionTransactionWitnessHash": "27819cb4d51cf2c2bcae7bd9765f259e4bb8d7a62c40ca8aef6df8c16ff2dc14" +} diff --git a/pkg/internal/tbtctest/testdata/redemption_scenario_1.json b/pkg/internal/tbtctest/testdata/redemption_scenario_1.json new file mode 100644 index 0000000000..4bf4ce92ae --- /dev/null +++ b/pkg/internal/tbtctest/testdata/redemption_scenario_1.json @@ -0,0 +1,32 @@ +{ + "Title": "single witness public key hash redemption with witness change", + "WalletPublicKey": "04989d253b17a6a0f41838b84ff0d20e8898f9d7b1a98f2564da4cc29dcf8581d9d218b65e7d91c752f7b22eaceb771a9af3a6f3d3f010a5d471a1aeef7d7713af", + "WalletPrivateKey": "7c246a5d2fcf476fd6f805cb8174b1cf441b13ea414e5560ca2bdc963aeb7d0c", + "WalletMainUtxo": { + "Outpoint": { + "TransactionHash": "c437f1117db977682334b53a71fbe63a42aab42f6e0976c35b69977f86308c20", + "OutputIndex": 1 + }, + "Value": 1472680 + }, + "RedemptionRequests": [ + { + "Redeemer": "82883a4c7a8dd73ef165deb402d432613615ced4", + "RedeemerOutputScript": "00144130879211c54df460e484ddf9aac009cb38ee74", + "RequestedAmount": 15000, + "TreasuryFee": 1100, + "TxMaxFee": 1700, + "RequestedAt": 1650623240 + } + ], + "InputTransaction": "0100000000010120dbe5aae74e9335f81b1c3160e563953333d733896cb7d35e4e8071fb4b3e520100000000ffffffff02e81c0000000000001976a9144130879211c54df460e484ddf9aac009cb38ee7488aca8781600000000001600148db50eb52063ea9d98b3eac91489a90f738986f602483045022100e1bcecbf3c6fc9a4ce2fc8029264d98a1bef4ff3d590816532097fbb93b7fdfb02206bca6c7af1db4c70d4d2c819eeb4c8430a291f5fe874c73c8f44acdd06c25d33012103989d253b17a6a0f41838b84ff0d20e8898f9d7b1a98f2564da4cc29dcf8581d900000000", + "FeeShares": [1700], + "Signature": { + "R": "ee8273dd93e85e8a0e0055498803335a370e3d25c51ad2890f0b61294e884e87", + "S": "4ebf3e04161b8172fbdf6070f7b1f22097f3d87c0bd32bc53a786971776e7b45" + }, + "ExpectedSigHash": "a95ed4632fbe50a632d9301b7be5165caf54ea92e85925ccbeda9fbe1b64a0ad", + "ExpectedRedemptionTransaction": "01000000000101208c30867f97695bc376096e2fb4aa423ae6fb713ab534236877b97d11f137c40100000000ffffffff02a82f0000000000001600144130879211c54df460e484ddf9aac009cb38ee745c421600000000001600148db50eb52063ea9d98b3eac91489a90f738986f602483045022100ee8273dd93e85e8a0e0055498803335a370e3d25c51ad2890f0b61294e884e8702204ebf3e04161b8172fbdf6070f7b1f22097f3d87c0bd32bc53a786971776e7b45012103989d253b17a6a0f41838b84ff0d20e8898f9d7b1a98f2564da4cc29dcf8581d900000000", + "ExpectedRedemptionTransactionHash": "925e61dc31396e7f2cbcc8bc9b4009b4f24ba679257762df078b7e9b875ea110", + "ExpectedRedemptionTransactionWitnessHash": "c14c41854ab88c617cd23c9cda711bc3027846fb9fe39c0a62663f2ead7048c7" +} diff --git a/pkg/internal/tbtctest/testdata/redemption_scenario_2.json b/pkg/internal/tbtctest/testdata/redemption_scenario_2.json new file mode 100644 index 0000000000..a7192541f8 --- /dev/null +++ b/pkg/internal/tbtctest/testdata/redemption_scenario_2.json @@ -0,0 +1,32 @@ +{ + "Title": "single non-witness script hash redemption with witness change", + "WalletPublicKey": "04989d253b17a6a0f41838b84ff0d20e8898f9d7b1a98f2564da4cc29dcf8581d9d218b65e7d91c752f7b22eaceb771a9af3a6f3d3f010a5d471a1aeef7d7713af", + "WalletPrivateKey": "7c246a5d2fcf476fd6f805cb8174b1cf441b13ea414e5560ca2bdc963aeb7d0c", + "WalletMainUtxo": { + "Outpoint": { + "TransactionHash": "925e61dc31396e7f2cbcc8bc9b4009b4f24ba679257762df078b7e9b875ea110", + "OutputIndex": 1 + }, + "Value": 1458780 + }, + "RedemptionRequests": [ + { + "Redeemer": "82883a4c7a8dd73ef165deb402d432613615ced4", + "RedeemerOutputScript": "a9143ec459d0f3c29286ae5df5fcc421e2786024277e87", + "RequestedAmount": 13000, + "TreasuryFee": 800, + "TxMaxFee": 1700, + "RequestedAt": 1650623240 + } + ], + "InputTransaction": "01000000000101208c30867f97695bc376096e2fb4aa423ae6fb713ab534236877b97d11f137c40100000000ffffffff02a82f0000000000001600144130879211c54df460e484ddf9aac009cb38ee745c421600000000001600148db50eb52063ea9d98b3eac91489a90f738986f602483045022100ee8273dd93e85e8a0e0055498803335a370e3d25c51ad2890f0b61294e884e8702204ebf3e04161b8172fbdf6070f7b1f22097f3d87c0bd32bc53a786971776e7b45012103989d253b17a6a0f41838b84ff0d20e8898f9d7b1a98f2564da4cc29dcf8581d900000000", + "FeeShares": [1700], + "Signature": { + "R": "9740ad12d2e74c00ccb4741d533d2ecd6902289144c4626508afb61eed790c97", + "S": "06e67179e8e2a63dc4f1ab758867d8bbfe0a2b67682be6dadfa8e07d3b7ba04d" + }, + "ExpectedSigHash": "f3900855bdfd64e6c9a1ed8dfbb899a7a46c034b88c21fb2db74f2194eee8b93", + "ExpectedRedemptionTransaction": "0100000000010110a15e879b7e8b07df62772579a64bf2b409409bbcc8bc2c7f6e3931dc615e920100000000ffffffff02042900000000000017a9143ec459d0f3c29286ae5df5fcc421e2786024277e87b4121600000000001600148db50eb52063ea9d98b3eac91489a90f738986f6024830450221009740ad12d2e74c00ccb4741d533d2ecd6902289144c4626508afb61eed790c97022006e67179e8e2a63dc4f1ab758867d8bbfe0a2b67682be6dadfa8e07d3b7ba04d012103989d253b17a6a0f41838b84ff0d20e8898f9d7b1a98f2564da4cc29dcf8581d900000000", + "ExpectedRedemptionTransactionHash": "ef25c9c8f4df673def035c0c1880278c90030b3c94a56668109001a591c2c521", + "ExpectedRedemptionTransactionWitnessHash": "ffab4704d49dce95698491ecc9957fceb87c9c811d43891661f57e6415826313" +} diff --git a/pkg/internal/tbtctest/testdata/redemption_scenario_3.json b/pkg/internal/tbtctest/testdata/redemption_scenario_3.json new file mode 100644 index 0000000000..26a183c7d2 --- /dev/null +++ b/pkg/internal/tbtctest/testdata/redemption_scenario_3.json @@ -0,0 +1,32 @@ +{ + "Title": "single witness script hash redemption with witness change", + "WalletPublicKey": "04989d253b17a6a0f41838b84ff0d20e8898f9d7b1a98f2564da4cc29dcf8581d9d218b65e7d91c752f7b22eaceb771a9af3a6f3d3f010a5d471a1aeef7d7713af", + "WalletPrivateKey": "7c246a5d2fcf476fd6f805cb8174b1cf441b13ea414e5560ca2bdc963aeb7d0c", + "WalletMainUtxo": { + "Outpoint": { + "TransactionHash": "ef25c9c8f4df673def035c0c1880278c90030b3c94a56668109001a591c2c521", + "OutputIndex": 1 + }, + "Value": 1446580 + }, + "RedemptionRequests": [ + { + "Redeemer": "82883a4c7a8dd73ef165deb402d432613615ced4", + "RedeemerOutputScript": "002086a303cdd2e2eab1d1679f1a813835dc5a1b65321077cdccaf08f98cbf04ca96", + "RequestedAmount": 18000, + "TreasuryFee": 1000, + "TxMaxFee": 1400, + "RequestedAt": 1650623240 + } + ], + "InputTransaction": "0100000000010110a15e879b7e8b07df62772579a64bf2b409409bbcc8bc2c7f6e3931dc615e920100000000ffffffff02042900000000000017a9143ec459d0f3c29286ae5df5fcc421e2786024277e87b4121600000000001600148db50eb52063ea9d98b3eac91489a90f738986f6024830450221009740ad12d2e74c00ccb4741d533d2ecd6902289144c4626508afb61eed790c97022006e67179e8e2a63dc4f1ab758867d8bbfe0a2b67682be6dadfa8e07d3b7ba04d012103989d253b17a6a0f41838b84ff0d20e8898f9d7b1a98f2564da4cc29dcf8581d900000000", + "FeeShares": [1400], + "Signature": { + "R": "bef6177f72f434248271cf5d18c1ce6add52dcf533ddda215240a858cb63cd07", + "S": "016a68c457f84f01108e1b001e8f81a9b073a3e08511265614318fa0d395ef4d" + }, + "ExpectedSigHash": "5402b1b44ab04b4223377904c44e1d10c4686f9dac5b89d96b907a53a71f098d", + "ExpectedRedemptionTransaction": "0100000000010121c5c291a50190106866a5943c0b03908c2780180c5c03ef3d67dff4c8c925ef0100000000ffffffff02f03c00000000000022002086a303cdd2e2eab1d1679f1a813835dc5a1b65321077cdccaf08f98cbf04ca964cd01500000000001600148db50eb52063ea9d98b3eac91489a90f738986f602483045022100bef6177f72f434248271cf5d18c1ce6add52dcf533ddda215240a858cb63cd070220016a68c457f84f01108e1b001e8f81a9b073a3e08511265614318fa0d395ef4d012103989d253b17a6a0f41838b84ff0d20e8898f9d7b1a98f2564da4cc29dcf8581d900000000", + "ExpectedRedemptionTransactionHash": "3d28bb5bf73379da51bc683f4d0ed31d7b024466c619d80ebd9378077d900be3", + "ExpectedRedemptionTransactionWitnessHash": "777729dcef49a094322c9e7735e22b193656eac4c16937ed9bb6e856570be7bb" +} diff --git a/pkg/internal/tbtctest/testdata/redemption_scenario_4.json b/pkg/internal/tbtctest/testdata/redemption_scenario_4.json new file mode 100644 index 0000000000..530299d8a5 --- /dev/null +++ b/pkg/internal/tbtctest/testdata/redemption_scenario_4.json @@ -0,0 +1,56 @@ +{ + "Title": "multiple redemptions with witness change", + "WalletPublicKey": "04989d253b17a6a0f41838b84ff0d20e8898f9d7b1a98f2564da4cc29dcf8581d9d218b65e7d91c752f7b22eaceb771a9af3a6f3d3f010a5d471a1aeef7d7713af", + "WalletPrivateKey": "7c246a5d2fcf476fd6f805cb8174b1cf441b13ea414e5560ca2bdc963aeb7d0c", + "WalletMainUtxo": { + "Outpoint": { + "TransactionHash": "3d28bb5bf73379da51bc683f4d0ed31d7b024466c619d80ebd9378077d900be3", + "OutputIndex": 1 + }, + "Value": 1429580 + }, + "RedemptionRequests": [ + { + "Redeemer": "82883a4c7a8dd73ef165deb402d432613615ced4", + "RedeemerOutputScript": "76a9144130879211c54df460e484ddf9aac009cb38ee7488ac", + "RequestedAmount": 18000, + "TreasuryFee": 1000, + "TxMaxFee": 1100, + "RequestedAt": 1650623240 + }, + { + "Redeemer": "82883a4c7a8dd73ef165deb402d432613615ced4", + "RedeemerOutputScript": "00144130879211c54df460e484ddf9aac009cb38ee74", + "RequestedAmount": 13000, + "TreasuryFee": 800, + "TxMaxFee": 900, + "RequestedAt": 1650623240 + }, + { + "Redeemer": "82883a4c7a8dd73ef165deb402d432613615ced4", + "RedeemerOutputScript": "a9143ec459d0f3c29286ae5df5fcc421e2786024277e87", + "RequestedAmount": 12000, + "TreasuryFee": 1100, + "TxMaxFee": 1000, + "RequestedAt": 1650623240 + }, + { + "Redeemer": "82883a4c7a8dd73ef165deb402d432613615ced4", + "RedeemerOutputScript": "002086a303cdd2e2eab1d1679f1a813835dc5a1b65321077cdccaf08f98cbf04ca96", + "RequestedAmount": 15000, + "TreasuryFee": 700, + "TxMaxFee": 1400, + "RequestedAt": 1650623240 + } + ], + "InputTransaction": "0100000000010121c5c291a50190106866a5943c0b03908c2780180c5c03ef3d67dff4c8c925ef0100000000ffffffff02f03c00000000000022002086a303cdd2e2eab1d1679f1a813835dc5a1b65321077cdccaf08f98cbf04ca964cd01500000000001600148db50eb52063ea9d98b3eac91489a90f738986f602483045022100bef6177f72f434248271cf5d18c1ce6add52dcf533ddda215240a858cb63cd070220016a68c457f84f01108e1b001e8f81a9b073a3e08511265614318fa0d395ef4d012103989d253b17a6a0f41838b84ff0d20e8898f9d7b1a98f2564da4cc29dcf8581d900000000", + "FeeShares": [1100, 900, 1000, 1400], + "Signature": { + "R": "adc5b0cffc65444cf16873eb57cb702414ee36ca907ad3cf57676abe83c0f805", + "S": "4a206d9b55eeee05d9647e95d15ed5143eaad6cc8c5bc2c1919b69addc18db29" + }, + "ExpectedSigHash": "47dcf9446a031f96ce0c1fad52533f3888e5c861d4eab105315a2ad6bb505eef", + "ExpectedRedemptionTransaction": "01000000000101e30b907d077893bd0ed819c66644027b1dd30e4d3f68bc51da7933f75bbb283d0100000000ffffffff051c3e0000000000001976a9144130879211c54df460e484ddf9aac009cb38ee7488ac242c0000000000001600144130879211c54df460e484ddf9aac009cb38ee74ac2600000000000017a9143ec459d0f3c29286ae5df5fcc421e2786024277e87643200000000000022002086a303cdd2e2eab1d1679f1a813835dc5a1b65321077cdccaf08f98cbf04ca96ccfb1400000000001600148db50eb52063ea9d98b3eac91489a90f738986f602483045022100adc5b0cffc65444cf16873eb57cb702414ee36ca907ad3cf57676abe83c0f80502204a206d9b55eeee05d9647e95d15ed5143eaad6cc8c5bc2c1919b69addc18db29012103989d253b17a6a0f41838b84ff0d20e8898f9d7b1a98f2564da4cc29dcf8581d900000000", + "ExpectedRedemptionTransactionHash": "f70ff89fd2b6226183e4b8143cc5f0f457f05dd1dca0c6151ab66f4523d972b7", + "ExpectedRedemptionTransactionWitnessHash": "dc748a8f2f9496c10bf4260e036886a1470f45da94eb0c3ab80c3afbdf0731da" +} diff --git a/pkg/internal/tbtctest/testdata/redemption_scenario_5.json b/pkg/internal/tbtctest/testdata/redemption_scenario_5.json new file mode 100644 index 0000000000..e07b65c35e --- /dev/null +++ b/pkg/internal/tbtctest/testdata/redemption_scenario_5.json @@ -0,0 +1,40 @@ +{ + "Title": "multiple redemptions without change", + "WalletPublicKey": "04989d253b17a6a0f41838b84ff0d20e8898f9d7b1a98f2564da4cc29dcf8581d9d218b65e7d91c752f7b22eaceb771a9af3a6f3d3f010a5d471a1aeef7d7713af", + "WalletPrivateKey": "7c246a5d2fcf476fd6f805cb8174b1cf441b13ea414e5560ca2bdc963aeb7d0c", + "WalletMainUtxo": { + "Outpoint": { + "TransactionHash": "7dd38b48cb626580d317871c5b716eaf4a952ceb67ba3aa4ca76e3dc7cdcc65b", + "OutputIndex": 1 + }, + "Value": 10000 + }, + "RedemptionRequests": [ + { + "Redeemer": "82883a4c7a8dd73ef165deb402d432613615ced4", + "RedeemerOutputScript": "76a9144130879211c54df460e484ddf9aac009cb38ee7488ac", + "RequestedAmount": 6000, + "TreasuryFee": 0, + "TxMaxFee": 800, + "RequestedAt": 1650623240 + }, + { + "Redeemer": "82883a4c7a8dd73ef165deb402d432613615ced4", + "RedeemerOutputScript": "00144bf9ffb7ae0f8b0f5a622b154aca829126f6e769", + "RequestedAmount": 4000, + "TreasuryFee": 0, + "TxMaxFee": 900, + "RequestedAt": 1650623240 + } + ], + "InputTransaction": "02000000000101c17208c443a3d3d2223884ef11ac83dadb1a3abe4d3474694414c8dcd3c697510100000000feffffff0224d38a5b0000000016001414c829f9d1770ebab98bd1acb39e428cffe7580310270000000000001600148db50eb52063ea9d98b3eac91489a90f738986f60247304402205d71cd954aa20b9c04266999baa8b2e1f04b7ecf419d48775ec78b81c3dbf6d5022076eb8cfc0f2fbd6178fdec4039570a1404c76fca4b72f6a34706b9c9c801ff7b0121033483097979eaff12af144dde368235592893fc2cb7477c3c4e34a0770f01f4e071832100", + "FeeShares": [800, 900], + "Signature": { + "R": "77174ae4d0a8e9d802f45b1a67cb4079abbbc4f110919b2e6b67fc991326e0ca", + "S": "6efb3d36a58f48d123e845fd868593af27e905adecb261d0120ba5b05ccc96fd" + }, + "ExpectedSigHash": "8b27c0666adc5e64fa0113acafa0929fb005ed8d6634c55ba6794011121b2f00", + "ExpectedRedemptionTransaction": "010000000001015bc6dc7cdce376caa43aba67eb2c954aaf6e715b1c8717d3806562cb488bd37d0100000000ffffffff0250140000000000001976a9144130879211c54df460e484ddf9aac009cb38ee7488ac1c0c0000000000001600144bf9ffb7ae0f8b0f5a622b154aca829126f6e76902473044022077174ae4d0a8e9d802f45b1a67cb4079abbbc4f110919b2e6b67fc991326e0ca02206efb3d36a58f48d123e845fd868593af27e905adecb261d0120ba5b05ccc96fd012103989d253b17a6a0f41838b84ff0d20e8898f9d7b1a98f2564da4cc29dcf8581d900000000", + "ExpectedRedemptionTransactionHash": "afcdf8f91273b73abc40018873978c22bbb7c3d8d669ef2faffa0c4b0898c8eb", + "ExpectedRedemptionTransactionWitnessHash": "3f295fdacebe5d979d4416779be66bff51237f1160f54f8237f8ab29a206846d" +} diff --git a/pkg/tbtc/deposit_sweep.go b/pkg/tbtc/deposit_sweep.go index 3462e15c97..a9e91d2d7a 100644 --- a/pkg/tbtc/deposit_sweep.go +++ b/pkg/tbtc/deposit_sweep.go @@ -555,10 +555,10 @@ func (dsa *depositSweepAction) actionType() WalletActionType { // assembleDepositSweepTransaction constructs an unsigned deposit sweep Bitcoin // transaction. // -// Regarding input arguments, the walletPublicKey parameter is optional and +// Regarding input arguments, the walletMainUtxo parameter is optional and // can be set as nil if the wallet does not have a main UTXO at the moment. // The deposits slice must contain at least one element. The fee argument -// is not validated anyway so must be chosen with respect to the system +// is not validated in any way so must be chosen with respect to the system // limitations. // // The resulting bitcoin.TransactionBuilder instance holds all the data diff --git a/pkg/tbtc/redemption.go b/pkg/tbtc/redemption.go new file mode 100644 index 0000000000..cd611224c1 --- /dev/null +++ b/pkg/tbtc/redemption.go @@ -0,0 +1,201 @@ +package tbtc + +import ( + "crypto/ecdsa" + "fmt" + "time" + + "github.com/keep-network/keep-core/pkg/bitcoin" + "github.com/keep-network/keep-core/pkg/chain" +) + +// RedemptionTransactionShape is an enum describing the shape of +// a Bitcoin redemption transaction. +type RedemptionTransactionShape uint8 + +const ( + // RedemptionChangeFirst is a shape where the change output is the first one + // in the transaction output vector. This shape makes the change's position + // fixed and leverages some SPV proof cost optimizations made in the Bridge + // implementation. + RedemptionChangeFirst RedemptionTransactionShape = iota + // RedemptionChangeLast is a shape where the change output is the last one + // in the transaction output vector. + RedemptionChangeLast +) + +// RedemptionRequest represents a tBTC redemption request. +type RedemptionRequest struct { + // Redeemer is the redeemer's address on the host chain. + Redeemer chain.Address + // RedeemerOutputScript is the output script the redeemed Bitcoin funds are + // locked to. This field is not prepended with the byte-length of the script. + RedeemerOutputScript []byte + // RequestedAmount is the TBTC amount (in satoshi) requested for redemption. + RequestedAmount uint64 + // TreasuryFee is the treasury TBTC fee (in satoshi) at the moment of + // request creation. + TreasuryFee uint64 + // TxMaxFee is the maximum value of the per-redemption BTC tx fee (in satoshi) + // that can be incurred by this request, determined at the moment of + // request creation. + TxMaxFee uint64 + // RequestedAt is the time the request was created at. + RequestedAt time.Time +} + +// redemptionFeeDistributionFn calculates the redemption transaction fee +// distribution for the given redemption requests. The resulting list +// contains the fee shares ordered in the same way as the input requests, i.e. +// the first fee share corresponds to the first request and so on. +type redemptionFeeDistributionFn func([]*RedemptionRequest) []int64 + +// withRedemptionTotalFee is a fee distribution function that takes a +// total transaction fee and distributes it evenly over all redemption requests. +// If the fee cannot be divided evenly, the last request incurs the remainder. +func withRedemptionTotalFee(totalFee int64) redemptionFeeDistributionFn { + return func(requests []*RedemptionRequest) []int64 { + requestsCount := int64(len(requests)) + remainder := totalFee % requestsCount + feePerRequest := (totalFee - remainder) / requestsCount + + feeShares := make([]int64, requestsCount) + for i := range requests { + feeShare := feePerRequest + + if i == len(requests)-1 { + feeShare += remainder + } + + feeShares[i] = feeShare + } + + return feeShares + } +} + +// assembleRedemptionTransaction constructs an unsigned redemption Bitcoin +// transaction. +// +// Regarding input arguments, the requests slice must contain at least one element. +// The fee shares applied to specific requests according to the provided +// feeDistribution function are not validated in any way so must be chosen with +// respect to the system limitations. The shape argument is optional - if not +// provided the RedemptionChangeFirst value is used by default. +// +// The resulting bitcoin.TransactionBuilder instance holds all the data +// necessary to sign the transaction and obtain a bitcoin.Transaction instance +// ready to be spread across the Bitcoin network. +func assembleRedemptionTransaction( + bitcoinChain bitcoin.Chain, + walletPublicKey *ecdsa.PublicKey, + walletMainUtxo *bitcoin.UnspentTransactionOutput, + requests []*RedemptionRequest, + feeDistribution redemptionFeeDistributionFn, + shape ...RedemptionTransactionShape, +) (*bitcoin.TransactionBuilder, error) { + resolvedShape := RedemptionChangeFirst + if len(shape) == 1 { + resolvedShape = shape[0] + } + + if walletMainUtxo == nil { + return nil, fmt.Errorf("wallet main UTXO is required") + } + + if len(requests) < 1 { + return nil, fmt.Errorf("at least one redemption request is required") + } + + builder := bitcoin.NewTransactionBuilder(bitcoinChain) + + err := builder.AddPublicKeyHashInput(walletMainUtxo) + if err != nil { + return nil, fmt.Errorf( + "cannot add input pointing to wallet main UTXO: [%v]", + err, + ) + } + + // Calculate the transaction fee shares for all redemption requests. + feeShares := feeDistribution(requests) + // Helper variable that will hold the total Bitcoin transaction fee. + totalFee := int64(0) + // Helper variable that will hold the summarized value of all redemption + // outputs. The change value will not be counted in here. + totalRedemptionOutputsValue := int64(0) + // List that will hold all transaction outputs, i.e. redemption outputs + // and the possible change output. + outputs := make([]*bitcoin.TransactionOutput, 0) + + // Create redemption outputs based on the provided redemption requests but + // do not add them to the transaction builder yet. The builder cannot be + // filled right now due to the change output that will be constructed in the + // next step and whose position in the transaction output vector depends on + // the requested RedemptionTransactionShape. + for i, request := range requests { + // The redeemable amount for a redemption request is the difference + // between the requested amount and treasury fee computed upon + // request creation. + redeemableAmount := int64(request.RequestedAmount - request.TreasuryFee) + // The actual value of the redemption output is the difference between + // the request's redeemable amount and share of the transaction fee + // incurred by the given request. + feeShare := feeShares[i] + redemptionOutputValue := redeemableAmount - feeShare + + totalFee += feeShare + totalRedemptionOutputsValue += redemptionOutputValue + + redemptionOutput := &bitcoin.TransactionOutput{ + Value: redemptionOutputValue, + PublicKeyScript: request.RedeemerOutputScript, + } + + outputs = append(outputs, redemptionOutput) + } + + // We know that the total fee of a Bitcoin transaction is the difference + // between the sum of inputs and the sum of outputs. In the case of a + // redemption transaction, that translates to the following formula: + // fee = main_utxo_input_value - (redemption_outputs_value + change_value) + // That means we can calculate the change's value using: + // change_value = main_utxo_input_value - redemption_outputs_value - fee + changeOutputValue := builder.TotalInputsValue() - + totalRedemptionOutputsValue - + totalFee + + // If we can have a non-zero change, construct it. + if changeOutputValue > 0 { + changeOutputScript, err := bitcoin.PayToWitnessPublicKeyHash( + bitcoin.PublicKeyHash(walletPublicKey), + ) + if err != nil { + return nil, fmt.Errorf( + "cannot compute change output script: [%v]", + err, + ) + } + + changeOutput := &bitcoin.TransactionOutput{ + Value: changeOutputValue, + PublicKeyScript: changeOutputScript, + } + + switch resolvedShape { + case RedemptionChangeFirst: + outputs = append([]*bitcoin.TransactionOutput{changeOutput}, outputs...) + case RedemptionChangeLast: + outputs = append(outputs, changeOutput) + default: + panic("unknown redemption transaction shape") + } + } + + // Finally, fill the builder with outputs constructed so far. + for _, output := range outputs { + builder.AddOutput(output) + } + + return builder, nil +} diff --git a/pkg/tbtc/redemption_test.go b/pkg/tbtc/redemption_test.go new file mode 100644 index 0000000000..77227ec0b3 --- /dev/null +++ b/pkg/tbtc/redemption_test.go @@ -0,0 +1,137 @@ +package tbtc + +import ( + "github.com/go-test/deep" + "testing" + + "github.com/keep-network/keep-core/pkg/bitcoin" + "github.com/keep-network/keep-core/pkg/internal/tbtctest" + "github.com/keep-network/keep-core/pkg/internal/testutils" +) + +func TestAssembleRedemptionTransaction(t *testing.T) { + scenarios, err := tbtctest.LoadRedemptionTestScenarios() + if err != nil { + t.Fatal(err) + } + + for _, scenario := range scenarios { + t.Run(scenario.Title, func(t *testing.T) { + bitcoinChain := newLocalBitcoinChain() + + err := bitcoinChain.BroadcastTransaction(scenario.InputTransaction) + if err != nil { + t.Fatal(err) + } + + requests := make([]*RedemptionRequest, len(scenario.RedemptionRequests)) + for i, r := range scenario.RedemptionRequests { + requests[i] = &RedemptionRequest{ + Redeemer: r.Redeemer, + RedeemerOutputScript: r.RedeemerOutputScript, + RequestedAmount: r.RequestedAmount, + TreasuryFee: r.TreasuryFee, + TxMaxFee: r.TxMaxFee, + RequestedAt: r.RequestedAt, + } + } + + feeDistribution := func(requests []*RedemptionRequest) []int64 { + return scenario.FeeShares + } + + builder, err := assembleRedemptionTransaction( + bitcoinChain, + scenario.WalletPublicKey, + scenario.WalletMainUtxo, + requests, + feeDistribution, + RedemptionChangeLast, + ) + if err != nil { + t.Fatal(err) + } + + sigHashes, err := builder.ComputeSignatureHashes() + if err != nil { + t.Fatal(err) + } + + testutils.AssertIntsEqual( + t, + "sighash count", + 1, + len(sigHashes), + ) + + testutils.AssertBigIntsEqual( + t, + "sighash", + scenario.ExpectedSigHash, + sigHashes[0], + ) + + transaction, err := builder.AddSignatures( + []*bitcoin.SignatureContainer{scenario.Signature}, + ) + if err != nil { + t.Fatal(err) + } + + testutils.AssertBytesEqual( + t, + scenario.ExpectedRedemptionTransaction.Serialize(), + transaction.Serialize(), + ) + testutils.AssertStringsEqual( + t, + "redemption transaction hash", + scenario.ExpectedRedemptionTransactionHash.Hex(bitcoin.InternalByteOrder), + transaction.Hash().Hex(bitcoin.InternalByteOrder), + ) + testutils.AssertStringsEqual( + t, + "redemption transaction witness hash", + scenario.ExpectedRedemptionTransactionWitnessHash.Hex(bitcoin.InternalByteOrder), + transaction.WitnessHash().Hex(bitcoin.InternalByteOrder), + ) + }) + } +} + +func TestWithRedemptionTotalFee(t *testing.T) { + var tests = map[string]struct { + totalFee int64 + requestsCount int + expectedFeeShares []int64 + }{ + "total fee divisible by the requests count": { + totalFee: 10000, + requestsCount: 5, + expectedFeeShares: []int64{2000, 2000, 2000, 2000, 2000}, + }, + "total fee indivisible by the requests count": { + totalFee: 10000, + requestsCount: 6, + expectedFeeShares: []int64{1666, 1666, 1666, 1666, 1666, 1670}, + }, + } + + for testName, test := range tests { + t.Run(testName, func(t *testing.T) { + requests := make([]*RedemptionRequest, test.requestsCount) + + feeShares := withRedemptionTotalFee(test.totalFee)(requests) + + if diff := deep.Equal(test.expectedFeeShares, feeShares); diff != nil { + t.Errorf( + "unexpected fee shares\n"+ + "expected: [%v]\n"+ + "actual: [%v]", + test.expectedFeeShares, + feeShares, + ) + } + }) + } +}