diff --git a/.github/workflows/nilaway.yml b/.github/workflows/nilaway.yml index ff736223..2d858e5b 100644 --- a/.github/workflows/nilaway.yml +++ b/.github/workflows/nilaway.yml @@ -14,6 +14,7 @@ jobs: nilaway: name: nilaway runs-on: ubuntu-latest + timeout-minutes: 10 steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 https://github.com/actions/checkout/releases/tag/v5.0.0 - uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0 https://github.com/actions/setup-go/releases/tag/v6.0.0 diff --git a/database/models/datum.go b/database/models/datum.go index ed61f18f..67647706 100644 --- a/database/models/datum.go +++ b/database/models/datum.go @@ -24,3 +24,15 @@ type Datum struct { func (Datum) TableName() string { return "datum" } + +// PlutusData represents a Plutus data value in the witness set +type PlutusData struct { + ID uint `gorm:"primaryKey"` + TransactionID uint `gorm:"index"` + Data []byte + Transaction *Transaction +} + +func (PlutusData) TableName() string { + return "plutus_data" +} diff --git a/database/models/models.go b/database/models/models.go index 43f31494..6ccb8956 100644 --- a/database/models/models.go +++ b/database/models/models.go @@ -26,6 +26,7 @@ var MigrateModels = []any{ &DeregistrationDrep{}, &Drep{}, &Epoch{}, + &KeyWitness{}, &Pool{}, &PoolRegistration{}, &PoolRegistrationOwner{}, @@ -33,9 +34,12 @@ var MigrateModels = []any{ &PoolRetirement{}, &PParams{}, &PParamUpdate{}, + &PlutusData{}, &Registration{}, &RegistrationDrep{}, + &Redeemer{}, &ResignCommitteeCold{}, + &Script{}, &StakeDelegation{}, &StakeDeregistration{}, &StakeRegistration{}, @@ -48,4 +52,5 @@ var MigrateModels = []any{ &Utxo{}, &VoteDelegation{}, &VoteRegistrationDelegation{}, + &WitnessScripts{}, } diff --git a/database/models/redeemer.go b/database/models/redeemer.go new file mode 100644 index 00000000..b63f79fd --- /dev/null +++ b/database/models/redeemer.go @@ -0,0 +1,31 @@ +// Copyright 2025 Blink Labs Software +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package models + +// Redeemer represents a redeemer in the witness set +type Redeemer struct { + ID uint `gorm:"primaryKey"` + TransactionID uint `gorm:"index"` + Tag uint8 `gorm:"index"` // Redeemer tag + Index uint32 `gorm:"index"` + Data []byte // Plutus data + ExUnitsMemory uint64 + ExUnitsCPU uint64 + Transaction *Transaction +} + +func (Redeemer) TableName() string { + return "redeemer" +} diff --git a/database/models/script.go b/database/models/script.go new file mode 100644 index 00000000..17e4a37f --- /dev/null +++ b/database/models/script.go @@ -0,0 +1,37 @@ +// Copyright 2025 Blink Labs Software +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package models + +// WitnessScripts represents a reference to a script in the witness set +// Type corresponds to ScriptRefType constants from gouroboros/ledger/common: +// 0=NativeScript (ScriptRefTypeNativeScript) +// 1=PlutusV1 (ScriptRefTypePlutusV1) +// 2=PlutusV2 (ScriptRefTypePlutusV2) +// 3=PlutusV3 (ScriptRefTypePlutusV3) +// +// To avoid storing duplicate script data for the same script used in multiple +// transactions, we store only the script hash here. The actual script content +// is stored separately in Script table, indexed by hash. +type WitnessScripts struct { + ID uint `gorm:"primaryKey"` + TransactionID uint `gorm:"index"` + Type uint8 `gorm:"index"` // Script type + ScriptHash []byte `gorm:"index"` // Hash of the script + Transaction *Transaction +} + +func (WitnessScripts) TableName() string { + return "witness_scripts" +} diff --git a/database/models/script_content.go b/database/models/script_content.go new file mode 100644 index 00000000..238ace62 --- /dev/null +++ b/database/models/script_content.go @@ -0,0 +1,30 @@ +// Copyright 2025 Blink Labs Software +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package models + +// Script represents the content of a script, indexed by its hash +// This avoids storing duplicate script data when the same script appears +// in multiple transactions +type Script struct { + ID uint `gorm:"primaryKey"` + Hash []byte `gorm:"index;unique"` // Script hash + Type uint8 `gorm:"index"` // Script type + Content []byte // Script content + CreatedSlot uint64 // Slot when this script was first seen +} + +func (Script) TableName() string { + return "script" +} diff --git a/database/models/transaction.go b/database/models/transaction.go index 36f719e4..4d1b6ea3 100644 --- a/database/models/transaction.go +++ b/database/models/transaction.go @@ -16,14 +16,18 @@ package models // Transaction represents a transaction record type Transaction struct { - Hash []byte `gorm:"uniqueIndex"` - BlockHash []byte `gorm:"index"` - Inputs []Utxo `gorm:"foreignKey:SpentAtTxId;references:Hash"` - Outputs []Utxo `gorm:"foreignKey:TransactionID;references:ID"` - ReferenceInputs []Utxo `gorm:"foreignKey:ReferencedByTxId;references:Hash"` - Collateral []Utxo `gorm:"foreignKey:CollateralByTxId;references:Hash"` - CollateralReturn *Utxo `gorm:"foreignKey:TransactionID;references:ID"` - ID uint `gorm:"primaryKey"` + Hash []byte `gorm:"uniqueIndex"` + BlockHash []byte `gorm:"index"` + Inputs []Utxo `gorm:"foreignKey:SpentAtTxId;references:Hash"` + Outputs []Utxo `gorm:"foreignKey:TransactionID;references:ID"` + ReferenceInputs []Utxo `gorm:"foreignKey:ReferencedByTxId;references:Hash"` + Collateral []Utxo `gorm:"foreignKey:CollateralByTxId;references:Hash"` + CollateralReturn *Utxo `gorm:"foreignKey:TransactionID;references:ID"` + KeyWitnesses []KeyWitness `gorm:"foreignKey:TransactionID;references:ID"` + WitnessScripts []WitnessScripts `gorm:"foreignKey:TransactionID;references:ID"` + Redeemers []Redeemer `gorm:"foreignKey:TransactionID;references:ID"` + PlutusData []PlutusData `gorm:"foreignKey:TransactionID;references:ID"` + ID uint `gorm:"primaryKey"` Type int BlockIndex uint32 Metadata []byte diff --git a/database/models/witness.go b/database/models/witness.go new file mode 100644 index 00000000..3788ed79 --- /dev/null +++ b/database/models/witness.go @@ -0,0 +1,40 @@ +// Copyright 2025 Blink Labs Software +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package models + +const ( + // KeyWitnessTypeVkey represents a Vkey witness + KeyWitnessTypeVkey uint8 = 0 + // KeyWitnessTypeBootstrap represents a Bootstrap witness + KeyWitnessTypeBootstrap uint8 = 1 +) + +// KeyWitness represents a key witness entry (Vkey or Bootstrap) +// Type: KeyWitnessTypeVkey = VkeyWitness, KeyWitnessTypeBootstrap = BootstrapWitness +type KeyWitness struct { + ID uint `gorm:"primaryKey"` + TransactionID uint `gorm:"index"` + Type uint8 `gorm:"index"` // See KeyWitnessType* constants + Vkey []byte // Vkey witness key + Signature []byte // Witness signature + PublicKey []byte // For Bootstrap witness + ChainCode []byte // For Bootstrap witness + Attributes []byte // For Bootstrap witness + Transaction *Transaction +} + +func (KeyWitness) TableName() string { + return "key_witness" +} diff --git a/database/plugin/metadata/sqlite/script.go b/database/plugin/metadata/sqlite/script.go new file mode 100644 index 00000000..92f162d2 --- /dev/null +++ b/database/plugin/metadata/sqlite/script.go @@ -0,0 +1,42 @@ +// Copyright 2025 Blink Labs Software +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sqlite + +import ( + "errors" + + "github.com/blinklabs-io/dingo/database/models" + lcommon "github.com/blinklabs-io/gouroboros/ledger/common" + "gorm.io/gorm" +) + +// GetScript returns the script content by its hash +func (d *MetadataStoreSqlite) GetScript( + hash lcommon.ScriptHash, + txn *gorm.DB, +) (*models.Script, error) { + ret := &models.Script{} + if txn == nil { + txn = d.DB() + } + result := txn.First(ret, "hash = ?", hash[:]) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return nil, nil + } + return nil, result.Error + } + return ret, nil +} diff --git a/database/plugin/metadata/sqlite/transaction.go b/database/plugin/metadata/sqlite/transaction.go index 4bf37ac8..4aa4ce2e 100644 --- a/database/plugin/metadata/sqlite/transaction.go +++ b/database/plugin/metadata/sqlite/transaction.go @@ -89,6 +89,19 @@ func (d *MetadataStoreSqlite) SetTransaction( if result.Error != nil { return fmt.Errorf("create transaction: %w", result.Error) } + // SQLite's ON CONFLICT clause doesn't return the ID of an existing row when + // the conflict path is taken (no insert occurs). We need to fetch the ID + // explicitly so we can associate witness records with the correct transaction. + if tmpTx.ID == 0 { + existingTx, err := d.GetTransactionByHash(txHash, txn) + if err != nil { + return fmt.Errorf("failed to fetch transaction ID after upsert: %w", err) + } + if existingTx == nil { + return fmt.Errorf("transaction not found after upsert: %x", txHash) + } + tmpTx.ID = existingTx.ID + } // Add Inputs to Transaction for _, input := range tx.Inputs() { inTxId := input.Id().Bytes() @@ -277,6 +290,150 @@ func (d *MetadataStoreSqlite) SetTransaction( return result.Error } } + // Extract and save witness set data + // Delete existing witness records to ensure idempotency on retry + if result := txn.Where("transaction_id = ?", tmpTx.ID).Delete(&models.KeyWitness{}); result.Error != nil { + return fmt.Errorf("delete existing key witnesses: %w", result.Error) + } + if result := txn.Where("transaction_id = ?", tmpTx.ID).Delete(&models.WitnessScripts{}); result.Error != nil { + return fmt.Errorf("delete existing witness scripts: %w", result.Error) + } + if result := txn.Where("transaction_id = ?", tmpTx.ID).Delete(&models.Redeemer{}); result.Error != nil { + return fmt.Errorf("delete existing redeemers: %w", result.Error) + } + if result := txn.Where("transaction_id = ?", tmpTx.ID).Delete(&models.PlutusData{}); result.Error != nil { + return fmt.Errorf("delete existing plutus data: %w", result.Error) + } + ws := tx.Witnesses() + if ws != nil { + // Add Vkey Witnesses + for _, vkey := range ws.Vkey() { + keyWitness := models.KeyWitness{ + TransactionID: tmpTx.ID, + Type: models.KeyWitnessTypeVkey, + Vkey: vkey.Vkey, + Signature: vkey.Signature, + } + if result := txn.Create(&keyWitness); result.Error != nil { + return fmt.Errorf("create vkey witness: %w", result.Error) + } + } + + // Add Bootstrap Witnesses + for _, bootstrap := range ws.Bootstrap() { + keyWitness := models.KeyWitness{ + TransactionID: tmpTx.ID, + Type: models.KeyWitnessTypeBootstrap, + PublicKey: bootstrap.PublicKey, + Signature: bootstrap.Signature, + ChainCode: bootstrap.ChainCode, + Attributes: bootstrap.Attributes, + } + if result := txn.Create(&keyWitness); result.Error != nil { + return fmt.Errorf("create bootstrap witness: %w", result.Error) + } + } + + // Helper function to process scripts - deduplicates the 4 script type blocks + processScripts := func(scriptType uint8, scripts interface{}) error { + // Common logic for creating script records + createScriptRecords := func(scriptHash lcommon.ScriptHash, content []byte) error { + witnessScript := models.WitnessScripts{ + TransactionID: tmpTx.ID, + Type: scriptType, + ScriptHash: scriptHash.Bytes(), + } + if result := txn.Create(&witnessScript); result.Error != nil { + return fmt.Errorf("create witness script: %w", result.Error) + } + scriptContent := models.Script{ + Hash: scriptHash.Bytes(), + Type: scriptType, + Content: content, + CreatedSlot: point.Slot, + } + if result := txn.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "hash"}}, + DoNothing: true, + }).Create(&scriptContent); result.Error != nil { + return fmt.Errorf("create script content: %w", result.Error) + } + return nil + } + + // Type switch to handle different script types + switch s := scripts.(type) { + case []lcommon.NativeScript: + for _, script := range s { + if err := createScriptRecords(script.Hash(), script.Cbor()); err != nil { + return err + } + } + case []lcommon.PlutusV1Script: + for _, script := range s { + if err := createScriptRecords(script.Hash(), script.RawScriptBytes()); err != nil { + return err + } + } + case []lcommon.PlutusV2Script: + for _, script := range s { + if err := createScriptRecords(script.Hash(), script.RawScriptBytes()); err != nil { + return err + } + } + case []lcommon.PlutusV3Script: + for _, script := range s { + if err := createScriptRecords(script.Hash(), script.RawScriptBytes()); err != nil { + return err + } + } + } + return nil + } + + // Process all script types + if err := processScripts(uint8(lcommon.ScriptRefTypeNativeScript), ws.NativeScripts()); err != nil { + return err + } + if err := processScripts(uint8(lcommon.ScriptRefTypePlutusV1), ws.PlutusV1Scripts()); err != nil { + return err + } + if err := processScripts(uint8(lcommon.ScriptRefTypePlutusV2), ws.PlutusV2Scripts()); err != nil { + return err + } + if err := processScripts(uint8(lcommon.ScriptRefTypePlutusV3), ws.PlutusV3Scripts()); err != nil { + return err + } + + // Add PlutusData (Datums) + for _, datum := range ws.PlutusData() { + plutusData := models.PlutusData{ + TransactionID: tmpTx.ID, + Data: datum.Cbor(), + } + if result := txn.Create(&plutusData); result.Error != nil { + return fmt.Errorf("create plutus data: %w", result.Error) + } + } + + // Add Redeemers + if ws.Redeemers() != nil { + for key, value := range ws.Redeemers().Iter() { + redeemer := models.Redeemer{ + TransactionID: tmpTx.ID, + Tag: uint8(key.Tag), + Index: key.Index, + Data: value.Data.Cbor(), + ExUnitsMemory: uint64(max(0, value.ExUnits.Memory)), //nolint:gosec + ExUnitsCPU: uint64(max(0, value.ExUnits.Steps)), //nolint:gosec + } + if result := txn.Create(&redeemer); result.Error != nil { + return fmt.Errorf("create redeemer: %w", result.Error) + } + } + } + } + // Avoid updating associations result = txn.Omit(clause.Associations).Save(&tmpTx) if result.Error != nil { diff --git a/database/plugin/metadata/store.go b/database/plugin/metadata/store.go index c5abf9a4..b5e0a4b7 100644 --- a/database/plugin/metadata/store.go +++ b/database/plugin/metadata/store.go @@ -83,6 +83,10 @@ type MetadataStore interface { []byte, // hash *gorm.DB, ) (*models.Transaction, error) + GetScript( + lcommon.ScriptHash, + *gorm.DB, + ) (*models.Script, error) SetBlockNonce( []byte, // blockHash