diff --git a/chainstate/definitions.go b/chainstate/definitions.go index 13d8f612..818526bf 100644 --- a/chainstate/definitions.go +++ b/chainstate/definitions.go @@ -4,6 +4,7 @@ import ( "time" "github.com/BuxOrg/bux/utils" + "github.com/libsv/go-bc" ) // Chainstate configuration defaults @@ -53,20 +54,19 @@ const ( // TransactionInfo is the universal information about the transaction found from a chain provider type TransactionInfo struct { - BlockHash string `json:"block_hash,omitempty"` // mAPI, WOC - BlockHeight int64 `json:"block_height"` // mAPI, WOC - Confirmations int64 `json:"confirmations,omitempty"` // mAPI, WOC - ID string `json:"id"` // Transaction ID (Hex) - MinerID string `json:"miner_id,omitempty"` // mAPI ONLY - miner_id found - Provider string `json:"provider,omitempty"` // Provider is our internal source + BlockHash string `json:"block_hash,omitempty"` // mAPI, WOC + BlockHeight int64 `json:"block_height"` // mAPI, WOC + Confirmations int64 `json:"confirmations,omitempty"` // mAPI, WOC + ID string `json:"id"` // Transaction ID (Hex) + MinerID string `json:"miner_id,omitempty"` // mAPI ONLY - miner_id found + Provider string `json:"provider,omitempty"` // Provider is our internal source + MerkleProof *bc.MerkleProof `json:"merkle_proof,omitempty"` // mAPI 1.5 ONLY. Should be also supported by Arc in future } -var ( - // DefaultFee is used when a fee has not been set by the user - // This default is currently accepted by all BitcoinSV miners (50/1000) (7.27.23) - // Actual TAAL FeeUnit - 1/1000, GorillaPool - 50/1000 (7.27.23) - DefaultFee = &utils.FeeUnit{ - Satoshis: 1, - Bytes: 20, - } -) +// DefaultFee is used when a fee has not been set by the user +// This default is currently accepted by all BitcoinSV miners (50/1000) (7.27.23) +// Actual TAAL FeeUnit - 1/1000, GorillaPool - 50/1000 (7.27.23) +var DefaultFee = &utils.FeeUnit{ + Satoshis: 1, + Bytes: 20, +} diff --git a/chainstate/transaction.go b/chainstate/transaction.go index 3e99ecd3..c2ef5733 100644 --- a/chainstate/transaction.go +++ b/chainstate/transaction.go @@ -159,7 +159,7 @@ func (c *Client) fastestQuery(ctx context.Context, id string, requiredIn Require // queryMinercraft will submit a query transaction request to a miner using Minercraft(mAPI or Arc) func queryMinercraft(ctx context.Context, client ClientInterface, miner *minercraft.Miner, id string) (*TransactionInfo, error) { client.DebugLog("executing request in minercraft using miner: " + miner.Name) - if resp, err := client.Minercraft().QueryTransaction(ctx, miner, id); err != nil { + if resp, err := client.Minercraft().QueryTransaction(ctx, miner, id, minercraft.WithQueryMerkleProof()); err != nil { client.DebugLog("error executing request in minercraft using miner: " + miner.Name + " failed: " + err.Error()) return nil, err } else if resp != nil && resp.Query.ReturnResult == mAPISuccess && strings.EqualFold(resp.Query.TxID, id) { @@ -170,6 +170,7 @@ func queryMinercraft(ctx context.Context, client ClientInterface, miner *minercr ID: resp.Query.TxID, MinerID: resp.Query.MinerID, Provider: miner.Name, + MerkleProof: resp.Query.MerkleProof, }, nil } return nil, ErrTransactionIDMismatch diff --git a/model_compound_merkle_path.go b/model_compound_merkle_path.go new file mode 100644 index 00000000..a59f08fb --- /dev/null +++ b/model_compound_merkle_path.go @@ -0,0 +1,127 @@ +package bux + +import ( + "bytes" + "database/sql/driver" + "encoding/json" + "errors" + "fmt" + "reflect" + "sort" +) + +// CompoundMerklePath represents Compound Merkle Path type +type CompoundMerklePath []map[string]uint64 + +// CMPSlice represents slice of Compound Merkle Pathes +// There must be several CMPs in case if utxos from different blocks is used in tx +type CMPSlice []CompoundMerklePath + +type nodeOffset struct { + node string + offset uint64 +} + +// Hex returns CMP in hex format +func (cmp *CompoundMerklePath) Hex() string { + var hex bytes.Buffer + hex.WriteString(leadingZeroInt(len(*cmp))) + + for _, m := range *cmp { + hex.WriteString(leadingZeroInt(len(m))) + sortedNodes := sortByOffset(m) + for _, n := range sortedNodes { + hex.WriteString(leadingZeroInt(int(n.offset))) + hex.WriteString(n.node) + } + } + return hex.String() +} + +func sortByOffset(m map[string]uint64) []nodeOffset { + n := make([]nodeOffset, 0) + for node, offset := range m { + n = append(n, nodeOffset{node, offset}) + } + sort.Slice(n, func(i, j int) bool { + return n[i].offset < n[j].offset + }) + return n +} + +// CalculateCompoundMerklePath calculates CMP from a slice of Merkle Proofs +func CalculateCompoundMerklePath(mp []MerkleProof) (CompoundMerklePath, error) { + if len(mp) == 0 || mp == nil { + return CompoundMerklePath{}, nil + } + height := len(mp[0].Nodes) + for _, m := range mp { + if height != len(m.Nodes) { + return nil, + errors.New("Compound Merkle Path cannot be obtained from Merkle Proofs of different heights") + } + } + cmp := make(CompoundMerklePath, height) + for _, m := range mp { + cmpToAdd := m.ToCompoundMerklePath() + err := cmp.add(cmpToAdd) + if err != nil { + return CompoundMerklePath{}, err + } + } + return cmp, nil +} + +// In case the offset or height is less than 10, they must be written with a leading zero +func leadingZeroInt(i int) string { + return fmt.Sprintf("%02d", i) +} + +func (cmp *CompoundMerklePath) add(c CompoundMerklePath) error { + if len(*cmp) != len(c) { + return errors.New("Compound Merkle Path with different height cannot be added") + } + for i := range c { + for k, v := range c[i] { + if (*cmp)[i] == nil { + (*cmp)[i] = c[i] + break + } + (*cmp)[i][k] = v + } + } + return nil +} + +// Scan scan value into Json, implements sql.Scanner interface +func (cmps *CMPSlice) Scan(value interface{}) error { + if value == nil { + return nil + } + + xType := fmt.Sprintf("%T", value) + var byteValue []byte + if xType == ValueTypeString { + byteValue = []byte(value.(string)) + } else { + byteValue = value.([]byte) + } + if bytes.Equal(byteValue, []byte("")) || bytes.Equal(byteValue, []byte("\"\"")) { + return nil + } + + return json.Unmarshal(byteValue, &cmps) +} + +// Value return json value, implement driver.Valuer interface +func (cmps CMPSlice) Value() (driver.Value, error) { + if reflect.DeepEqual(cmps, CMPSlice{}) { + return nil, nil + } + marshal, err := json.Marshal(cmps) + if err != nil { + return nil, err + } + + return string(marshal), nil +} diff --git a/model_compound_merkle_path_test.go b/model_compound_merkle_path_test.go new file mode 100644 index 00000000..169847f8 --- /dev/null +++ b/model_compound_merkle_path_test.go @@ -0,0 +1,225 @@ +package bux + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// TestCompoundMerklePathModel_CalculateCompoundMerklePath will test the method CalculateCompoundMerklePath() +func TestCompoundMerklePathModel_CalculateCompoundMerklePath(t *testing.T) { + t.Parallel() + + t.Run("Single Merkle Proof", func(t *testing.T) { + signleMerkleProof := []MerkleProof{ + { + Index: 1, + TxOrID: "txId", + Nodes: []string{"node0", "node1", "node2", "node3"}, + }, + } + expectedCMP := CompoundMerklePath( + []map[string]uint64{ + { + "node0": 0, + "txId": 1, + }, + { + "node1": 1, + }, + { + "node2": 1, + }, + { + "node3": 1, + }, + }, + ) + cmp, err := CalculateCompoundMerklePath(signleMerkleProof) + assert.NoError(t, err) + assert.Equal(t, expectedCMP, cmp) + }) + + t.Run("Slice of Merkle Proofs", func(t *testing.T) { + signleMerkleProof := []MerkleProof{ + { + Index: 2, + TxOrID: "txId1", + Nodes: []string{"D", "AB", "EFGH", "IJKLMNOP"}, + }, + { + Index: 7, + TxOrID: "txId2", + Nodes: []string{"G", "EF", "ABCD", "IJKLMNOP"}, + }, + { + Index: 13, + TxOrID: "txId3", + Nodes: []string{"M", "OP", "IJKL", "ABCDEFGH"}, + }, + } + expectedCMP := CompoundMerklePath( + []map[string]uint64{ + { + "txId1": 2, + "D": 3, + "G": 6, + "txId2": 7, + "M": 12, + "txId3": 13, + }, + { + "AB": 0, + "EF": 2, + "OP": 7, + }, + { + "ABCD": 0, + "EFGH": 1, + "IJKL": 2, + }, + { + "ABCDEFGH": 0, + "IJKLMNOP": 1, + }, + }, + ) + cmp, err := CalculateCompoundMerklePath(signleMerkleProof) + assert.NoError(t, err) + assert.Equal(t, expectedCMP, cmp) + }) + + t.Run("Paired Transactions", func(t *testing.T) { + signleMerkleProof := []MerkleProof{ + { + Index: 8, + TxOrID: "I", + Nodes: []string{"J", "KL", "MNOP", "ABCDEFGH"}, + }, + { + Index: 9, + TxOrID: "J", + Nodes: []string{"I", "KL", "MNOP", "ABCDEFGH"}, + }, + } + expectedCMP := CompoundMerklePath( + []map[string]uint64{ + { + "I": 8, + "J": 9, + }, + { + "KL": 5, + }, + { + "MNOP": 3, + }, + { + "ABCDEFGH": 0, + }, + }, + ) + cmp, err := CalculateCompoundMerklePath(signleMerkleProof) + assert.NoError(t, err) + assert.Equal(t, expectedCMP, cmp) + }) + + t.Run("Different sizes of Merkle Proofs", func(t *testing.T) { + signleMerkleProof := []MerkleProof{ + { + Index: 8, + TxOrID: "I", + Nodes: []string{"J", "KL", "MNOP", "ABCDEFGH"}, + }, + { + Index: 9, + TxOrID: "J", + Nodes: []string{"I", "KL", "MNOP"}, + }, + } + cmp, err := CalculateCompoundMerklePath(signleMerkleProof) + assert.Error(t, err) + assert.Nil(t, cmp) + }) + + t.Run("Empty slice of Merkle Proofs", func(t *testing.T) { + signleMerkleProof := []MerkleProof{} + cmp, err := CalculateCompoundMerklePath(signleMerkleProof) + assert.NoError(t, err) + assert.Equal(t, cmp, CompoundMerklePath{}) + }) + + t.Run("Slice of empty Merkle Proofs", func(t *testing.T) { + signleMerkleProof := []MerkleProof{ + {}, {}, {}, + } + cmp, err := CalculateCompoundMerklePath(signleMerkleProof) + assert.NoError(t, err) + assert.Equal(t, cmp, CompoundMerklePath{}) + }) +} + +// TestCompoundMerklePathModel_Hex will test the method Hex() +func TestCompoundMerklePathModel_Hex(t *testing.T) { + t.Run("Sorted Compound Merkle Path", func(t *testing.T) { + cmp := CompoundMerklePath( + []map[string]uint64{ + { + "txId1": 2, + "D": 3, + "G": 6, + "txId2": 7, + "M": 12, + "txId3": 13, + }, + { + "AB": 0, + "EF": 2, + "OP": 7, + }, + { + "ABCD": 0, + "EFGH": 1, + "IJKL": 2, + }, + { + "ABCDEFGH": 0, + "IJKLMNOP": 1, + }, + }, + ) + expectedHex := "040602txId103D06G07txId212M13txId30300AB02EF07OP0300ABCD01EFGH02IJKL0200ABCDEFGH01IJKLMNOP" + hex := cmp.Hex() + assert.Equal(t, hex, expectedHex) + }) + + t.Run("Unsorted Compound Merkle Path", func(t *testing.T) { + cmp := CompoundMerklePath( + []map[string]uint64{ + { + "F": 5, + "E": 4, + "C": 2, + "D": 3, + "G": 6, + "H": 7, + "B": 1, + "A": 0, + }, + { + "GH": 3, + "AB": 0, + "EF": 2, + "CD": 1, + }, + { + "ABCD": 0, + "EFGH": 1, + }, + }, + ) + expectedHex := "030800A01B02C03D04E05F06G07H0400AB01CD02EF03GH0200ABCD01EFGH" + hex := cmp.Hex() + assert.Equal(t, hex, expectedHex) + }) +} diff --git a/model_draft_transactions.go b/model_draft_transactions.go index 20e2619b..80f8c309 100644 --- a/model_draft_transactions.go +++ b/model_draft_transactions.go @@ -32,11 +32,12 @@ type DraftTransaction struct { TransactionBase `bson:",inline"` // Model specific fields - XpubID string `json:"xpub_id" toml:"xpub_id" yaml:"xpub_id" gorm:"<-:create;type:char(64);index;comment:This is the related xPub" bson:"xpub_id"` - ExpiresAt time.Time `json:"expires_at" toml:"expires_at" yaml:"expires_at" gorm:"<-:create;comment:Time when the draft expires" bson:"expires_at"` - Configuration TransactionConfig `json:"configuration" toml:"configuration" yaml:"configuration" gorm:"<-;type:text;comment:This is the configuration struct in JSON" bson:"configuration"` - Status DraftStatus `json:"status" toml:"status" yaml:"status" gorm:"<-;type:varchar(10);index;comment:This is the status of the draft" bson:"status"` - FinalTxID string `json:"final_tx_id,omitempty" toml:"final_tx_id" yaml:"final_tx_id" gorm:"<-;type:char(64);index;comment:This is the final tx ID" bson:"final_tx_id,omitempty"` + XpubID string `json:"xpub_id" toml:"xpub_id" yaml:"xpub_id" gorm:"<-:create;type:char(64);index;comment:This is the related xPub" bson:"xpub_id"` + ExpiresAt time.Time `json:"expires_at" toml:"expires_at" yaml:"expires_at" gorm:"<-:create;comment:Time when the draft expires" bson:"expires_at"` + Configuration TransactionConfig `json:"configuration" toml:"configuration" yaml:"configuration" gorm:"<-;type:text;comment:This is the configuration struct in JSON" bson:"configuration"` + Status DraftStatus `json:"status" toml:"status" yaml:"status" gorm:"<-;type:varchar(10);index;comment:This is the status of the draft" bson:"status"` + FinalTxID string `json:"final_tx_id,omitempty" toml:"final_tx_id" yaml:"final_tx_id" gorm:"<-;type:char(64);index;comment:This is the final tx ID" bson:"final_tx_id,omitempty"` + CompoundMerklePathes CMPSlice `json:"compound_merkle_pathes,omitempty" toml:"compound_merkle_pathes" yaml:"compound_merkle_pathes" gorm:"<-;type:text;comment:Slice of Compound Merkle Path" bson:"compound_merkle_pathes,omitempty"` } // newDraftTransaction will start a new draft tx @@ -381,6 +382,7 @@ func (m *DraftTransaction) createTransactionHex(ctx context.Context) (err error) // final sanity check inputValue := uint64(0) usedUtxos := make([]string, 0) + merkleProofs := make(map[uint64][]MerkleProof) for _, input := range m.Configuration.Inputs { // check whether an utxo was used twice, this is not valid if utils.StringInSlice(input.Utxo.ID, usedUtxos) { @@ -388,6 +390,13 @@ func (m *DraftTransaction) createTransactionHex(ctx context.Context) (err error) } usedUtxos = append(usedUtxos, input.Utxo.ID) inputValue += input.Satoshis + tx, err := m.client.GetTransactionByID(ctx, input.UtxoPointer.TransactionID) + if err != nil { + return err + } + if tx.MerkleProof.TxOrID != "" { + merkleProofs[tx.BlockHeight] = append(merkleProofs[tx.BlockHeight], tx.MerkleProof) + } } outputValue := uint64(0) for _, output := range m.Configuration.Outputs { @@ -404,6 +413,13 @@ func (m *DraftTransaction) createTransactionHex(ctx context.Context) (err error) return ErrTransactionFeeInvalid } + for _, v := range merkleProofs { + cmp, err := CalculateCompoundMerklePath(v) + if err != nil { + return err + } + m.CompoundMerklePathes = append(m.CompoundMerklePathes, cmp) + } // Create the final hex (without signatures) m.Hex = tx.String() diff --git a/model_draft_transactions_test.go b/model_draft_transactions_test.go index 2397568a..c9c850e8 100644 --- a/model_draft_transactions_test.go +++ b/model_draft_transactions_test.go @@ -184,7 +184,6 @@ func TestDraftTransaction_setChangeDestinations(t *testing.T) { // TestDraftTransaction_getDraftTransactionID tests getting the draft transaction by draft id func TestDraftTransaction_getDraftTransactionID(t *testing.T) { - t.Run("not found", func(t *testing.T) { ctx, client, deferMe := CreateTestSQLiteClient(t, false, false, WithCustomTaskManager(&taskManagerMockBase{})) defer deferMe() @@ -210,7 +209,6 @@ func TestDraftTransaction_getDraftTransactionID(t *testing.T) { // TestDraftTransaction_createTransaction create a transaction hex func TestDraftTransaction_createTransaction(t *testing.T) { - t.Run("empty transaction", func(t *testing.T) { ctx, client, deferMe := CreateTestSQLiteClient(t, false, false, WithCustomTaskManager(&taskManagerMockBase{})) defer deferMe() @@ -235,7 +233,7 @@ func TestDraftTransaction_createTransaction(t *testing.T) { }) t.Run("transaction with utxos", func(t *testing.T) { - ctx, client, deferMe := CreateTestSQLiteClient(t, false, false, WithCustomTaskManager(&taskManagerMockBase{})) + ctx, client, deferMe := CreateTestSQLiteClient(t, false, true, WithCustomTaskManager(&taskManagerMockBase{})) defer deferMe() xPub := newXpub(testXPub, append(client.DefaultModelOptions(), New())...) err := xPub.Save(ctx) @@ -251,6 +249,10 @@ func TestDraftTransaction_createTransaction(t *testing.T) { err = utxo.Save(ctx) require.NoError(t, err) + transaction := newTransaction(testTxHex, append(client.DefaultModelOptions(), New())...) + err = transaction.Save(ctx) + require.NoError(t, err) + draftTransaction := newDraftTransaction(testXPub, &TransactionConfig{ Outputs: []*TransactionOutput{{ To: testExternalAddress, @@ -303,7 +305,7 @@ func TestDraftTransaction_createTransaction(t *testing.T) { }) t.Run("send to all", func(t *testing.T) { - ctx, client, deferMe := CreateTestSQLiteClient(t, false, false, WithCustomTaskManager(&taskManagerMockBase{})) + ctx, client, deferMe := CreateTestSQLiteClient(t, false, true, WithCustomTaskManager(&taskManagerMockBase{})) defer deferMe() xPub := newXpub(testXPub, append(client.DefaultModelOptions(), New())...) err := xPub.Save(ctx) @@ -319,6 +321,10 @@ func TestDraftTransaction_createTransaction(t *testing.T) { err = utxo.Save(ctx) require.NoError(t, err) + transaction := newTransaction(testTxHex, append(client.DefaultModelOptions(), New())...) + err = transaction.Save(ctx) + require.NoError(t, err) + draftTransaction := newDraftTransaction(testXPub, &TransactionConfig{ SendAllTo: &TransactionOutput{To: testExternalAddress}, }, append(client.DefaultModelOptions(), New())...) @@ -339,7 +345,7 @@ func TestDraftTransaction_createTransaction(t *testing.T) { }) t.Run("fee calculation - MAP", func(t *testing.T) { - ctx, client, deferMe := CreateTestSQLiteClient(t, false, false, WithCustomTaskManager(&taskManagerMockBase{})) + ctx, client, deferMe := CreateTestSQLiteClient(t, false, true, WithCustomTaskManager(&taskManagerMockBase{})) defer deferMe() xPub := newXpub(testXPub, append(client.DefaultModelOptions(), New())...) err := xPub.Save(ctx) @@ -355,6 +361,10 @@ func TestDraftTransaction_createTransaction(t *testing.T) { err = utxo.Save(ctx) require.NoError(t, err) + transaction := newTransaction(testTxHex, append(client.DefaultModelOptions(), New())...) + err = transaction.Save(ctx) + require.NoError(t, err) + draftTransaction := newDraftTransaction(testXPub, &TransactionConfig{ Outputs: []*TransactionOutput{{ To: testExternalAddress, @@ -389,7 +399,7 @@ func TestDraftTransaction_createTransaction(t *testing.T) { }) t.Run("fee calculation - MAP 2", func(t *testing.T) { - ctx, client, deferMe := CreateTestSQLiteClient(t, false, false, WithCustomTaskManager(&taskManagerMockBase{})) + ctx, client, deferMe := CreateTestSQLiteClient(t, false, true, WithCustomTaskManager(&taskManagerMockBase{})) defer deferMe() xPub := newXpub(testXPub, append(client.DefaultModelOptions(), New())...) err := xPub.Save(ctx) @@ -410,6 +420,10 @@ func TestDraftTransaction_createTransaction(t *testing.T) { err = utxo.Save(ctx) require.NoError(t, err) + transaction := newTransaction(testTxHex, append(client.DefaultModelOptions(), New())...) + err = transaction.Save(ctx) + require.NoError(t, err) + draftTransaction := newDraftTransaction(testXPub, &TransactionConfig{ Outputs: []*TransactionOutput{{ To: testExternalAddress, @@ -447,7 +461,7 @@ func TestDraftTransaction_createTransaction(t *testing.T) { }) t.Run("fee calculation - tonicpow", func(t *testing.T) { - ctx, client, deferMe := CreateTestSQLiteClient(t, false, false, WithCustomTaskManager(&taskManagerMockBase{})) + ctx, client, deferMe := CreateTestSQLiteClient(t, false, true, WithCustomTaskManager(&taskManagerMockBase{})) defer deferMe() xPub := newXpub(testXPub, append(client.DefaultModelOptions(), New())...) err := xPub.Save(ctx) @@ -463,6 +477,10 @@ func TestDraftTransaction_createTransaction(t *testing.T) { err = utxo.Save(ctx) require.NoError(t, err) + transaction := newTransaction(testTxHex, append(client.DefaultModelOptions(), New())...) + err = transaction.Save(ctx) + require.NoError(t, err) + draftTransaction := newDraftTransaction(testXPub, &TransactionConfig{ FeeUnit: &utils.FeeUnit{ Satoshis: 5, @@ -498,7 +516,7 @@ func TestDraftTransaction_createTransaction(t *testing.T) { }) t.Run("send to all - multiple utxos", func(t *testing.T) { - ctx, client, deferMe := CreateTestSQLiteClient(t, false, false, WithCustomTaskManager(&taskManagerMockBase{})) + ctx, client, deferMe := CreateTestSQLiteClient(t, false, true, WithCustomTaskManager(&taskManagerMockBase{})) defer deferMe() xPub := newXpub(testXPub, append(client.DefaultModelOptions(), New())...) err := xPub.Save(ctx) @@ -522,6 +540,10 @@ func TestDraftTransaction_createTransaction(t *testing.T) { err = utxo.Save(ctx) require.NoError(t, err) + transaction := newTransaction(testTxHex, append(client.DefaultModelOptions(), New())...) + err = transaction.Save(ctx) + require.NoError(t, err) + draftTransaction := newDraftTransaction(testXPub, &TransactionConfig{ SendAllTo: &TransactionOutput{To: testExternalAddress}, }, append(client.DefaultModelOptions(), New())...) @@ -542,7 +564,7 @@ func TestDraftTransaction_createTransaction(t *testing.T) { }) t.Run("send to all - selected utxos", func(t *testing.T) { - ctx, client, deferMe := CreateTestSQLiteClient(t, false, false, WithCustomTaskManager(&taskManagerMockBase{})) + ctx, client, deferMe := CreateTestSQLiteClient(t, false, true, WithCustomTaskManager(&taskManagerMockBase{})) defer deferMe() xPub := newXpub(testXPub, append(client.DefaultModelOptions(), New())...) err := xPub.Save(ctx) @@ -566,6 +588,10 @@ func TestDraftTransaction_createTransaction(t *testing.T) { err = utxo.Save(ctx) require.NoError(t, err) + transaction := newTransaction(testTxHex, append(client.DefaultModelOptions(), New())...) + err = transaction.Save(ctx) + require.NoError(t, err) + draftTransaction := newDraftTransaction(testXPub, &TransactionConfig{ SendAllTo: &TransactionOutput{To: testExternalAddress}, FromUtxos: []*UtxoPointer{{ @@ -593,7 +619,7 @@ func TestDraftTransaction_createTransaction(t *testing.T) { }) t.Run("include utxos - tokens", func(t *testing.T) { - ctx, client, deferMe := CreateTestSQLiteClient(t, false, false, WithCustomTaskManager(&taskManagerMockBase{})) + ctx, client, deferMe := CreateTestSQLiteClient(t, false, true, WithCustomTaskManager(&taskManagerMockBase{})) defer deferMe() xPub := newXpub(testXPub, append(client.DefaultModelOptions(), New())...) err := xPub.Save(ctx) @@ -622,6 +648,10 @@ func TestDraftTransaction_createTransaction(t *testing.T) { err = utxo.Save(ctx) require.NoError(t, err) + transaction := newTransaction(testTxHex, append(client.DefaultModelOptions(), New())...) + err = transaction.Save(ctx) + require.NoError(t, err) + // todo how to make sure we do not unwittingly destroy tokens ? draftTransaction := newDraftTransaction(testXPub, &TransactionConfig{ IncludeUtxos: []*UtxoPointer{{ @@ -757,7 +787,7 @@ func TestDraftTransaction_createTransaction(t *testing.T) { t.Run("SendAllTo + 2 utxos", func(t *testing.T) { p := newTestPaymailClient(t, []string{"handcash.io"}) - ctx, client, deferMe := CreateTestSQLiteClient(t, false, false, + ctx, client, deferMe := CreateTestSQLiteClient(t, false, true, WithCustomTaskManager(&taskManagerMockBase{}), WithPaymailClient(p), ) @@ -791,6 +821,10 @@ func TestDraftTransaction_createTransaction(t *testing.T) { err = utxo.Save(ctx) require.NoError(t, err) + transaction := newTransaction(testTxHex, append(client.DefaultModelOptions(), New())...) + err = transaction.Save(ctx) + require.NoError(t, err) + draftTransaction := newDraftTransaction(testXPub, &TransactionConfig{ FromUtxos: []*UtxoPointer{{ TransactionID: testTxID, @@ -1374,11 +1408,9 @@ func TestDraftTransaction_estimateFees(t *testing.T) { // TestDraftTransaction_RegisterTasks will test the method RegisterTasks() func TestDraftTransaction_RegisterTasks(t *testing.T) { - draftCleanupTask := "draft_transaction_clean_up" t.Run("testing task: "+draftCleanupTask, func(t *testing.T) { - _, client, deferMe := CreateTestSQLiteClient(t, false, false) defer deferMe() @@ -1392,7 +1424,7 @@ func TestDraftTransaction_RegisterTasks(t *testing.T) { } func TestDraftTransaction_SignInputs(t *testing.T) { - ctx, client, deferMe := CreateTestSQLiteClient(t, true, false) + ctx, client, deferMe := CreateTestSQLiteClient(t, true, true) defer deferMe() xPrivString := "xprv9s21ZrQH143K31pvNoYNcRZjtdJXnNVEc5NmBbgJmEg27YWbZVL7jTLQhPELqAR7tcJTnF9AJLwVN5w3ABZvrfeDLm4vnBDw76bkx8a2NxK" @@ -1436,11 +1468,14 @@ func TestDraftTransaction_SignInputs(t *testing.T) { require.NoError(t, err) // create a utxo with enough output for all our tests - txID := "aa9cd99d6c1c179de6ab9785f6d27d6589a72c1523f1fc37de041a66dbbdbdb6" - utxo := newUtxo(xPubID, txID, lockingScript, 0, 12229, client.DefaultModelOptions(New())...) + utxo := newUtxo(xPubID, testTxID, lockingScript, 0, 12229, client.DefaultModelOptions(New())...) err = utxo.Save(ctx) require.NoError(t, err) + transaction := newTransaction(testTxHex, append(client.DefaultModelOptions(), New())...) + err = transaction.Save(ctx) + require.NoError(t, err) + tests := []struct { name string config *TransactionConfig @@ -1523,5 +1558,9 @@ func initSimpleTestCase(t *testing.T) (context.Context, ClientInterface, func()) err = utxo.Save(ctx) require.NoError(t, err) + transaction := newTransaction(testTxHex, append(client.DefaultModelOptions(), New())...) + err = transaction.Save(ctx) + require.NoError(t, err) + return ctx, client, deferMe } diff --git a/model_merkle_proof.go b/model_merkle_proof.go new file mode 100644 index 00000000..24234727 --- /dev/null +++ b/model_merkle_proof.go @@ -0,0 +1,79 @@ +package bux + +import ( + "bytes" + "database/sql/driver" + "encoding/json" + "fmt" + "reflect" + + "github.com/libsv/go-bc" +) + +// MerkleProof represents Merkle Proof type +type MerkleProof bc.MerkleProof + +// ToCompoundMerklePath transform Merkle Proof to Compound Merkle Path +func (m MerkleProof) ToCompoundMerklePath() CompoundMerklePath { + height := len(m.Nodes) + if height == 0 { + return nil + } + cmp := make(CompoundMerklePath, height) + pathMap := make(map[string]uint64, 2) + offset := m.Index + pathMap[m.TxOrID] = offset + pathMap[m.Nodes[0]] = offsetPair(offset) + cmp[0] = pathMap + for i := 1; i < height; i++ { + path := make(map[string]uint64, 1) + offset = parrentOffset(offset) + path[m.Nodes[i]] = offset + cmp[i] = path + } + return cmp +} + +func offsetPair(offset uint64) uint64 { + if offset%2 == 0 { + return offset + 1 + } + return offset - 1 +} + +func parrentOffset(offset uint64) uint64 { + return offsetPair(offset / 2) +} + +// Scan scan value into Json, implements sql.Scanner interface +func (m *MerkleProof) Scan(value interface{}) error { + if value == nil { + return nil + } + + xType := fmt.Sprintf("%T", value) + var byteValue []byte + if xType == ValueTypeString { + byteValue = []byte(value.(string)) + } else { + byteValue = value.([]byte) + } + if bytes.Equal(byteValue, []byte("")) || bytes.Equal(byteValue, []byte("\"\"")) { + return nil + } + + return json.Unmarshal(byteValue, &m) +} + +// Value return json value, implement driver.Valuer interface +func (m MerkleProof) Value() (driver.Value, error) { + if reflect.DeepEqual(m, MerkleProof{}) { + return nil, nil + } + marshal, err := json.Marshal(m) + if err != nil { + return nil, err + } + + return string(marshal), nil +} diff --git a/model_merkle_proof_test.go b/model_merkle_proof_test.go new file mode 100644 index 00000000..b6c268b1 --- /dev/null +++ b/model_merkle_proof_test.go @@ -0,0 +1,75 @@ +package bux + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// TestMerkleProofModel_ToCompoundMerklePath will test the method ToCompoundMerklePath() +func TestMerkleProofModel_ToCompoundMerklePath(t *testing.T) { + t.Parallel() + + t.Run("Valid Merkle Proof #1", func(t *testing.T) { + mp := MerkleProof{ + Index: 1, + TxOrID: "txId", + Nodes: []string{"node0", "node1", "node2", "node3"}, + } + expectedCMP := CompoundMerklePath( + []map[string]uint64{ + { + "node0": 0, + "txId": 1, + }, + { + "node1": 1, + }, + { + "node2": 1, + }, + { + "node3": 1, + }, + }, + ) + cmp := mp.ToCompoundMerklePath() + assert.Equal(t, expectedCMP, cmp) + }) + + t.Run("Valid Merkle Proof #2", func(t *testing.T) { + mp := MerkleProof{ + Index: 14, + TxOrID: "txId", + Nodes: []string{"node0", "node1", "node2", "node3", "node4"}, + } + expectedCMP := CompoundMerklePath( + []map[string]uint64{ + { + "txId": 14, + "node0": 15, + }, + { + "node1": 6, + }, + { + "node2": 2, + }, + { + "node3": 0, + }, + { + "node4": 1, + }, + }, + ) + cmp := mp.ToCompoundMerklePath() + assert.Equal(t, expectedCMP, cmp) + }) + + t.Run("Empty Merkle Proof", func(t *testing.T) { + mp := MerkleProof{} + cmp := mp.ToCompoundMerklePath() + assert.Nil(t, cmp) + }) +} diff --git a/model_sync_transactions.go b/model_sync_transactions.go index 0f51fcf7..dcec02bf 100644 --- a/model_sync_transactions.go +++ b/model_sync_transactions.go @@ -411,7 +411,7 @@ func processSyncTransactions(ctx context.Context, maxTransactions int, opts ...M Page: 1, PageSize: maxTransactions, OrderByField: "created_at", - SortDirection: "asc", + SortDirection: "desc", } // Get x records @@ -642,7 +642,8 @@ func processSyncTransaction(ctx context.Context, syncTx *SyncTransaction, transa // Find on-chain var txInfo *chainstate.TransactionInfo - if txInfo, err = syncTx.Client().Chainstate().QueryTransactionFastest( + // only mAPI currently provides merkle proof, so QueryTransaction should be used here + if txInfo, err = syncTx.Client().Chainstate().QueryTransaction( ctx, syncTx.ID, chainstate.RequiredOnChain, defaultQueryTxTimeout, ); err != nil { if errors.Is(err, chainstate.ErrTransactionNotFound) { @@ -670,6 +671,7 @@ func processSyncTransaction(ctx context.Context, syncTx *SyncTransaction, transa // Add additional information (if found on-chain) transaction.BlockHash = txInfo.BlockHash transaction.BlockHeight = uint64(txInfo.BlockHeight) + transaction.MerkleProof = MerkleProof(*txInfo.MerkleProof) // Create status message message := "transaction was found on-chain by " + chainstate.ProviderBroadcastClient diff --git a/model_transactions.go b/model_transactions.go index ec258434..1ae9f8b5 100644 --- a/model_transactions.go +++ b/model_transactions.go @@ -58,6 +58,7 @@ type Transaction struct { TotalValue uint64 `json:"total_value" toml:"total_value" yaml:"total_value" gorm:"<-create;type:bigint" bson:"total_value,omitempty"` XpubMetadata XpubMetadata `json:"-" toml:"xpub_metadata" gorm:"<-;type:json;xpub_id specific metadata" bson:"xpub_metadata,omitempty"` XpubOutputValue XpubOutputValue `json:"-" toml:"xpub_output_value" gorm:"<-;type:json;xpub_id specific value" bson:"xpub_output_value,omitempty"` + MerkleProof MerkleProof `json:"merkle_proof" toml:"merkle_proof" yaml:"merkle_proof" gorm:"<-;type:text;comment:Merkle Proof payload from mAPI" bson:"merkle_proof,omitempty"` // Virtual Fields OutputValue int64 `json:"output_value" toml:"-" yaml:"-" gorm:"-" bson:"-,omitempty"`