Skip to content
12 changes: 12 additions & 0 deletions database/models/datum.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 `gorm:"type:bytea"`
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove the GORM field type override. This is a Postgres column type that won't work in SQLite

Transaction *Transaction
}

func (PlutusData) TableName() string {
return "plutus_data"
}
4 changes: 4 additions & 0 deletions database/models/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,16 +26,20 @@ var MigrateModels = []any{
&DeregistrationDrep{},
&Drep{},
&Epoch{},
&KeyWitness{},
&Pool{},
&PoolRegistration{},
&PoolRegistrationOwner{},
&PoolRegistrationRelay{},
&PoolRetirement{},
&PParams{},
&PParamUpdate{},
&PlutusData{},
&Registration{},
&RegistrationDrep{},
&Redeemer{},
&ResignCommitteeCold{},
&Script{},
&StakeDelegation{},
&StakeDeregistration{},
&StakeRegistration{},
Expand Down
31 changes: 31 additions & 0 deletions database/models/redeemer.go
Original file line number Diff line number Diff line change
@@ -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 `gorm:"type:bytea"` // Plutus data
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a Postgres data type, which is not supported by SQLite. We should let GORM determine the correct type to use under the hood where it can.

ExUnitsMemory uint64
ExUnitsCPU uint64
Transaction *Transaction
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We probably don't want/need this reference back to Transaction when we've already got the TransactionId field

}

func (Redeemer) TableName() string {
return "redeemer"
}
33 changes: 33 additions & 0 deletions database/models/script.go
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rename this file to witness_script.go

Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// 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 a script entry 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)
type Script struct {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rename this to WitnessScripts to be a bit more specific about its function (mapping TX ID to script hash when the script appears in the TX witness)

ID uint `gorm:"primaryKey"`
TransactionID uint `gorm:"index"`
Type uint8 `gorm:"index"` // Script type
ScriptData []byte `gorm:"type:bytea"`
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a Postgres data type, which is not supported by SQLite. We should let GORM determine the correct type to use under the hood where it can.

Transaction *Transaction
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We probably don't want/need this reference back to Transaction when we've already got the TransactionId field

}

func (Script) TableName() string {
return "script"
}
20 changes: 12 additions & 8 deletions database/models/transaction.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Scripts []Script `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
Expand Down
33 changes: 33 additions & 0 deletions database/models/witness.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// 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

// KeyWitness represents a key witness entry (Vkey or Bootstrap)
// Type: 0 = VkeyWitness, 1 = BootstrapWitness
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Create constants for these

type KeyWitness struct {
ID uint `gorm:"primaryKey"`
TransactionID uint `gorm:"index"`
Type uint8 `gorm:"index"` // 0=Vkey, 1=Bootstrap
Vkey []byte `gorm:"type:bytea"`
Signature []byte `gorm:"type:bytea"`
PublicKey []byte `gorm:"type:bytea"` // For Bootstrap
ChainCode []byte `gorm:"type:bytea"` // For Bootstrap
Attributes []byte `gorm:"type:bytea"` // For Bootstrap
Transaction *Transaction
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We probably don't want/need this reference back to Transaction when we've already got the TransactionId field

}

func (KeyWitness) TableName() string {
return "key_witness"
}
135 changes: 135 additions & 0 deletions database/plugin/metadata/sqlite/transaction.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,17 @@ func (d *MetadataStoreSqlite) SetTransaction(
if result.Error != nil {
return fmt.Errorf("create transaction: %w", result.Error)
}
// If ID is still zero (conflict path with SQLite), fetch it by hash
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()
Expand Down Expand Up @@ -277,6 +288,130 @@ func (d *MetadataStoreSqlite) SetTransaction(
return result.Error
}
}
// Extract and save witness set data
// Delete existing witness records to ensure idempotency on retry
if tmpTx.ID != 0 {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove this condition. We assign the ID above when it's 0 and throw an error otherwise, so this check will always succeed.

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.Script{}); result.Error != nil {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ScriptWitness

return fmt.Errorf("delete existing 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)
}
}
if tx.Witnesses() != nil {
ws := tx.Witnesses()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can save a repeated function call by doing:

if ws := tx.Witnesses(); ws != nil {


// Add Vkey Witnesses
for _, vkey := range ws.Vkey() {
keyWitness := models.KeyWitness{
TransactionID: tmpTx.ID,
Type: 0, // VkeyWitness
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should define our own constants for the "key witness" type, since there aren't any provided by gOuroboros.

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: 1, // BootstrapWitness
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should define our own constants for the "key witness" type, since there aren't any provided by gOuroboros.

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)
}
}

// Add Native Scripts
for _, script := range ws.NativeScripts() {
scriptRecord := models.Script{
TransactionID: tmpTx.ID,
Type: uint8(lcommon.ScriptRefTypeNativeScript),
ScriptData: script.Cbor(),
}
if result := txn.Create(&scriptRecord); result.Error != nil {
return fmt.Errorf("create native script: %w", result.Error)
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's no need for this type switch. All 4 types have the Hash() and RawScriptBytes() functions, so you can simply loop over scripts and call the same code.

for _, script := range s {
  if err := createScriptRecords(script.Hash(), script.RawScriptBytes()); err != nil {
    return err
  }
}

}

// Add PlutusV1 Scripts
for _, script := range ws.PlutusV1Scripts() {
scriptRecord := models.Script{
TransactionID: tmpTx.ID,
Type: uint8(lcommon.ScriptRefTypePlutusV1),
ScriptData: script,
}
if result := txn.Create(&scriptRecord); result.Error != nil {
return fmt.Errorf("create plutus v1 script: %w", result.Error)
}
}

// Add PlutusV2 Scripts
for _, script := range ws.PlutusV2Scripts() {
scriptRecord := models.Script{
TransactionID: tmpTx.ID,
Type: uint8(lcommon.ScriptRefTypePlutusV2),
ScriptData: script,
}
if result := txn.Create(&scriptRecord); result.Error != nil {
return fmt.Errorf("create plutus v2 script: %w", result.Error)
}
}

// Add PlutusV3 Scripts
for _, script := range ws.PlutusV3Scripts() {
scriptRecord := models.Script{
TransactionID: tmpTx.ID,
Type: uint8(lcommon.ScriptRefTypePlutusV3),
ScriptData: script,
}
if result := txn.Create(&scriptRecord); result.Error != nil {
return fmt.Errorf("create plutus v3 script: %w", result.Error)
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You could de-duplicate the above 4 blocks by processing them all at once:

scripts := []lcommon.Script{
  ws.NativeScripts(),
  ws.PlutusV1Scripts(),
  ...
}
for _, script := range scripts {
  ...
  var scriptType int
  switch script.(type) {
  case lcommon.NativeScript:
    scriptType = lcommon.ScriptTypeNative
  ...
  }


// 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 {
Expand Down
Loading