Skip to content

Commit a8384e2

Browse files
feat(database): track collateral and reference inputs (#977)
Signed-off-by: Chris Gianelloni <wolf31o2@blinklabs.io> Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
1 parent 6275fdc commit a8384e2

File tree

3 files changed

+209
-47
lines changed

3 files changed

+209
-47
lines changed

database/models/transaction.go

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,16 @@ package models
1616

1717
// Transaction represents a transaction record
1818
type Transaction struct {
19-
Hash []byte `gorm:"uniqueIndex"`
20-
BlockHash []byte `gorm:"index"`
21-
Inputs []Utxo `gorm:"foreignKey:SpentAtTxId;references:Hash"`
22-
Outputs []Utxo `gorm:"foreignKey:TransactionID;references:ID"`
23-
ID uint `gorm:"primaryKey"`
24-
Type int
25-
BlockIndex uint32
19+
Hash []byte `gorm:"uniqueIndex"`
20+
BlockHash []byte `gorm:"index"`
21+
Inputs []Utxo `gorm:"foreignKey:SpentAtTxId;references:Hash"`
22+
Outputs []Utxo `gorm:"foreignKey:TransactionID;references:ID"`
23+
ReferenceInputs []Utxo `gorm:"foreignKey:ReferencedByTxId;references:Hash"`
24+
Collateral []Utxo `gorm:"foreignKey:CollateralByTxId;references:Hash"`
25+
CollateralReturn *Utxo `gorm:"foreignKey:TransactionID;references:ID"`
26+
ID uint `gorm:"primaryKey"`
27+
Type int
28+
BlockIndex uint32
2629
}
2730

2831
func (Transaction) TableName() string {

database/models/utxo.go

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -21,18 +21,20 @@ import (
2121

2222
// Utxo represents an unspent transaction output
2323
type Utxo struct {
24-
TransactionID *uint `gorm:"index"`
25-
TxId []byte `gorm:"index:tx_id_output_idx"`
26-
PaymentKey []byte `gorm:"index"`
27-
StakingKey []byte `gorm:"index"`
28-
Assets []Asset
29-
Cbor []byte `gorm:"-"` // This is here for convenience but not represented in the metadata DB
30-
SpentAtTxId []byte `gorm:"index"`
31-
ID uint `gorm:"primarykey"`
32-
AddedSlot uint64 `gorm:"index"`
33-
DeletedSlot uint64 `gorm:"index"`
34-
Amount uint64 `gorm:"index"`
35-
OutputIdx uint32 `gorm:"index:tx_id_output_idx"`
24+
TransactionID *uint `gorm:"index"`
25+
TxId []byte `gorm:"uniqueIndex:tx_id_output_idx"`
26+
PaymentKey []byte `gorm:"index"`
27+
StakingKey []byte `gorm:"index"`
28+
Assets []Asset
29+
Cbor []byte `gorm:"-"` // This is here for convenience but not represented in the metadata DB
30+
SpentAtTxId []byte `gorm:"index"`
31+
ReferencedByTxId []byte `gorm:"index"`
32+
CollateralByTxId []byte `gorm:"index"`
33+
ID uint `gorm:"primarykey"`
34+
AddedSlot uint64 `gorm:"index"`
35+
DeletedSlot uint64 `gorm:"index"`
36+
Amount uint64 `gorm:"index"`
37+
OutputIdx uint32 `gorm:"uniqueIndex:tx_id_output_idx"`
3638
}
3739

3840
func (u *Utxo) TableName() string {

database/plugin/metadata/sqlite/transaction.go

Lines changed: 185 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ package sqlite
1717
import (
1818
"errors"
1919
"fmt"
20+
"strings"
2021

2122
"github.com/blinklabs-io/dingo/database/models"
2223
lcommon "github.com/blinklabs-io/gouroboros/ledger/common"
@@ -34,7 +35,9 @@ func (d *MetadataStoreSqlite) GetTransactionByHash(
3435
if txn == nil {
3536
txn = d.DB()
3637
}
37-
result := txn.First(ret, "hash = ?", hash)
38+
result := txn.
39+
Preload(clause.Associations).
40+
First(ret, "hash = ?", hash)
3841
if result.Error != nil {
3942
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
4043
return nil, nil
@@ -51,40 +54,195 @@ func (d *MetadataStoreSqlite) SetTransaction(
5154
idx uint32,
5255
txn *gorm.DB,
5356
) error {
57+
txHash := tx.Hash().Bytes()
5458
if txn == nil {
5559
txn = d.DB()
5660
}
5761
tmpTx := &models.Transaction{
58-
Hash: tx.Hash().Bytes(),
62+
Hash: txHash,
5963
Type: tx.Type(),
6064
BlockHash: point.Hash,
6165
BlockIndex: idx,
6266
}
63-
if tx.IsValid() {
64-
for _, utxo := range tx.Produced() {
65-
tmpTx.Outputs = append(
66-
tmpTx.Outputs,
67-
models.UtxoLedgerToModel(utxo, point.Slot),
68-
)
67+
collateralReturn := tx.CollateralReturn()
68+
for _, utxo := range tx.Produced() {
69+
if collateralReturn != nil && utxo.Output == collateralReturn {
70+
utxo := models.UtxoLedgerToModel(utxo, point.Slot)
71+
tmpTx.CollateralReturn = &utxo
72+
continue
6973
}
74+
tmpTx.Outputs = append(
75+
tmpTx.Outputs,
76+
models.UtxoLedgerToModel(utxo, point.Slot),
77+
)
7078
}
71-
result := txn.Create(&tmpTx)
79+
result := txn.Clauses(clause.OnConflict{
80+
Columns: []clause.Column{{Name: "hash"}}, // unique txn hash
81+
DoUpdates: clause.AssignmentColumns(
82+
[]string{"block_hash", "block_index"},
83+
),
84+
}).Create(&tmpTx)
7285
if result.Error != nil {
7386
return fmt.Errorf("create transaction: %w", result.Error)
7487
}
75-
// Explicitly create produced outputs with TransactionID set
76-
if tx.IsValid() && len(tmpTx.Outputs) > 0 {
77-
for o := range tmpTx.Outputs {
78-
tmpTx.Outputs[o].TransactionID = &tmpTx.ID
88+
// Add Inputs to Transaction
89+
for _, input := range tx.Inputs() {
90+
inTxId := input.Id().Bytes()
91+
inIdx := input.Index()
92+
utxo, err := d.GetUtxo(inTxId, inIdx, txn)
93+
if err != nil {
94+
return fmt.Errorf(
95+
"failed to fetch input %x#%d: %w",
96+
inTxId,
97+
inIdx,
98+
err,
99+
)
79100
}
80-
result = txn.Clauses(clause.OnConflict{
81-
UpdateAll: true,
82-
}).Create(&tmpTx.Outputs)
83-
if result.Error != nil {
84-
return fmt.Errorf("create outputs: %w", result.Error)
101+
if utxo == nil {
102+
d.logger.Warn(
103+
"Skipping missing input UTxO",
104+
"hash",
105+
input.Id().String(),
106+
"index",
107+
inIdx,
108+
)
109+
continue
110+
}
111+
tmpTx.Inputs = append(
112+
tmpTx.Inputs,
113+
*utxo,
114+
)
115+
}
116+
// Add Collateral to Transaction
117+
if len(tx.Collateral()) > 0 {
118+
var caseClauses []string
119+
var whereConditions []string
120+
var caseArgs []any
121+
var whereArgs []any
122+
123+
for _, input := range tx.Collateral() {
124+
inTxId := input.Id().Bytes()
125+
inIdx := input.Index()
126+
utxo, err := d.GetUtxo(inTxId, inIdx, txn)
127+
if err != nil {
128+
return fmt.Errorf(
129+
"failed to fetch input %x#%d: %w",
130+
inTxId,
131+
inIdx,
132+
err,
133+
)
134+
}
135+
if utxo == nil {
136+
d.logger.Warn(
137+
"Skipping missing collateral UTxO",
138+
"hash",
139+
input.Id().String(),
140+
"index",
141+
inIdx,
142+
)
143+
continue
144+
}
145+
// Found the Utxo, add it to the SQL UPDATE list
146+
// First, add it to the CASE statement so it's selected
147+
caseClauses = append(
148+
caseClauses,
149+
"WHEN tx_id = ? AND output_idx = ? THEN ?",
150+
)
151+
caseArgs = append(caseArgs, inTxId, inIdx, txHash)
152+
// Also add it to the WHERE clause in the SQL UPDATE
153+
whereConditions = append(
154+
whereConditions,
155+
"(tx_id = ? AND output_idx = ?)",
156+
)
157+
whereArgs = append(whereArgs, inTxId, inIdx)
158+
// Add it to the Transaction
159+
tmpTx.Collateral = append(
160+
tmpTx.Collateral,
161+
*utxo,
162+
)
163+
}
164+
// Update reference where this Utxo was used as collateral in a Transaction
165+
if len(caseClauses) > 0 {
166+
args := append(caseArgs, whereArgs...)
167+
sql := fmt.Sprintf(
168+
"UPDATE utxo SET collateral_by_tx_id = CASE %s ELSE collateral_by_tx_id END WHERE %s",
169+
strings.Join(caseClauses, " "),
170+
strings.Join(whereConditions, " OR "),
171+
)
172+
result = txn.Exec(sql, args...)
173+
if result.Error != nil {
174+
return fmt.Errorf("batch update collateral: %w", result.Error)
175+
}
85176
}
86177
}
87-
for i, input := range tx.Consumed() {
178+
// Add ReferenceInputs to Transaction
179+
if len(tx.ReferenceInputs()) > 0 {
180+
var caseClauses []string
181+
var whereConditions []string
182+
var caseArgs []any
183+
var whereArgs []any
184+
185+
for _, input := range tx.ReferenceInputs() {
186+
inTxId := input.Id().Bytes()
187+
inIdx := input.Index()
188+
utxo, err := d.GetUtxo(inTxId, inIdx, txn)
189+
if err != nil {
190+
return fmt.Errorf(
191+
"failed to fetch input %x#%d: %w",
192+
inTxId,
193+
inIdx,
194+
err,
195+
)
196+
}
197+
if utxo == nil {
198+
d.logger.Warn(
199+
"Skipping missing reference input UTxO",
200+
"hash",
201+
input.Id().String(),
202+
"index",
203+
inIdx,
204+
)
205+
continue
206+
}
207+
// Found the Utxo, add it to the SQL UPDATE list
208+
// First, add it to the CASE statement so it's selected
209+
caseClauses = append(
210+
caseClauses,
211+
"WHEN tx_id = ? AND output_idx = ? THEN ?",
212+
)
213+
caseArgs = append(caseArgs, inTxId, inIdx, txHash)
214+
// Also add it to the WHERE clause in the SQL UPDATE
215+
whereConditions = append(
216+
whereConditions,
217+
"(tx_id = ? AND output_idx = ?)",
218+
)
219+
whereArgs = append(whereArgs, inTxId, inIdx)
220+
// Add it to the Transaction
221+
tmpTx.ReferenceInputs = append(
222+
tmpTx.ReferenceInputs,
223+
*utxo,
224+
)
225+
}
226+
// Update reference where this Utxo was used as a reference input in a Transaction
227+
if len(caseClauses) > 0 {
228+
args := append(caseArgs, whereArgs...)
229+
sql := fmt.Sprintf(
230+
"UPDATE utxo SET referenced_by_tx_id = CASE %s ELSE referenced_by_tx_id END WHERE %s",
231+
strings.Join(caseClauses, " "),
232+
strings.Join(whereConditions, " OR "),
233+
)
234+
result = txn.Exec(sql, args...)
235+
if result.Error != nil {
236+
return fmt.Errorf(
237+
"batch update reference inputs: %w",
238+
result.Error,
239+
)
240+
}
241+
}
242+
}
243+
244+
// Consume input UTxOs
245+
for _, input := range tx.Consumed() {
88246
inTxId := input.Id().Bytes()
89247
inIdx := input.Index()
90248
utxo, err := d.GetUtxo(inTxId, inIdx, txn)
@@ -97,27 +255,26 @@ func (d *MetadataStoreSqlite) SetTransaction(
97255
)
98256
}
99257
if utxo == nil {
100-
return fmt.Errorf("input UTxO not found: %x#%d", inTxId, inIdx)
258+
d.logger.Warn(
259+
fmt.Sprintf("input UTxO not found: %x#%d", inTxId, inIdx),
260+
)
261+
continue
262+
// return fmt.Errorf("input UTxO not found: %x#%d", inTxId, inIdx)
101263
}
102264
// Update existing UTxOs
103265
result = txn.Model(&models.Utxo{}).
104266
Where("tx_id = ? AND output_idx = ?", inTxId, inIdx).
267+
Where("spent_at_tx_id IS NULL OR spent_at_tx_id = ?", txHash).
105268
Updates(map[string]any{
106269
"deleted_slot": point.Slot,
107-
"spent_at_tx_id": tx.Hash().Bytes(),
270+
"spent_at_tx_id": txHash,
108271
})
109272
if result.Error != nil {
110273
return result.Error
111274
}
112-
// Explicitly set consumed inputs with TransactionID set
113-
tmpTx.Inputs = append(
114-
tmpTx.Inputs,
115-
*utxo,
116-
)
117-
tmpTx.Inputs[i].TransactionID = &utxo.ID
118275
}
119276
// Avoid updating associations
120-
result = txn.Omit(clause.Associations).Save(&tmpTx.Inputs)
277+
result = txn.Omit(clause.Associations).Save(&tmpTx)
121278
if result.Error != nil {
122279
return result.Error
123280
}

0 commit comments

Comments
 (0)