Skip to content

Commit

Permalink
feat(SPV-1387, SPV-1396): replace estimated unlocking script size wit…
Browse files Browse the repository at this point in the history
…h estimated input size; rename UsersUTXO (#857)
  • Loading branch information
dorzepowski authored Jan 20, 2025
1 parent b06ac5a commit d4c9702
Show file tree
Hide file tree
Showing 13 changed files with 98 additions and 89 deletions.
2 changes: 1 addition & 1 deletion engine/database/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ func Models() []any {
User{},
Paymail{},
Address{},
UserUtxos{},
UserUTXO{},
Operation{},
}
}
2 changes: 1 addition & 1 deletion engine/database/repository/users.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ func (u *Users) GetBalance(ctx context.Context, userID string, bucket string) (b
var balance bsv.Satoshis
err := u.db.
WithContext(ctx).
Model(&database.UserUtxos{}).
Model(&database.UserUTXO{}).
Where("user_id = ? AND bucket = ?", userID, bucket).
Select("COALESCE(SUM(satoshis), 0)").
Row().
Expand Down
2 changes: 1 addition & 1 deletion engine/database/testabilities/fixture_database.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ type UserUtxoFixture interface {
// WithSatoshis sets the satoshis value of the UTXO.
WithSatoshis(satoshis bsv.Satoshis) UserUtxoFixture

Storable[database.UserUtxos]
Storable[database.UserUTXO]
}

type Storable[Data any] interface {
Expand Down
55 changes: 28 additions & 27 deletions engine/database/testabilities/user_utxo_fixture.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,32 +8,33 @@ import (
"github.com/bitcoin-sv/spv-wallet/engine/database"
"github.com/bitcoin-sv/spv-wallet/engine/tester/fixtures"
"github.com/bitcoin-sv/spv-wallet/models/bsv"
"github.com/bitcoin-sv/spv-wallet/models/transaction/bucket"
"gorm.io/gorm"
)

var FirstCreatedAt = time.Date(2006, 02, 01, 15, 4, 5, 7, time.UTC)

type userUtxoFixture struct {
db *gorm.DB
t testing.TB
index uint
userID string
txID string
vout uint32
satoshis bsv.Satoshis
unlockingScriptEstimatedSize uint64
db *gorm.DB
t testing.TB
index uint
userID string
txID string
vout uint32
satoshis bsv.Satoshis
estimatedInputSize uint64
}

func newUtxoFixture(t testing.TB, db *gorm.DB, index uint32) *userUtxoFixture {
return &userUtxoFixture{
t: t,
db: db,
index: uint(index),
userID: fixtures.Sender.ID(),
txID: txIDTemplated(uint(index)),
vout: index,
satoshis: 1,
unlockingScriptEstimatedSize: 106,
t: t,
db: db,
index: uint(index),
userID: fixtures.Sender.ID(),
txID: txIDTemplated(uint(index)),
vout: index,
satoshis: 1,
estimatedInputSize: database.EstimatedInputSizeForP2PKH,
}
}

Expand All @@ -47,7 +48,7 @@ func (f *userUtxoFixture) OwnedBySender() UserUtxoFixture {
}

func (f *userUtxoFixture) P2PKH() UserUtxoFixture {
f.unlockingScriptEstimatedSize = fixtures.EstimatedUnlockingScriptSizeForP2PKH
f.estimatedInputSize = database.EstimatedInputSizeForP2PKH
return f
}

Expand All @@ -56,16 +57,16 @@ func (f *userUtxoFixture) WithSatoshis(satoshis bsv.Satoshis) UserUtxoFixture {
return f
}

func (f *userUtxoFixture) Stored() *database.UserUtxos {
utxo := &database.UserUtxos{
UserID: f.userID,
TxID: f.txID,
Vout: f.vout,
Satoshis: uint64(f.satoshis),
UnlockingScriptEstimatedSize: f.unlockingScriptEstimatedSize,
Bucket: "bsv",
CreatedAt: FirstCreatedAt.Add(time.Duration(f.index) * time.Second), //nolint:gosec // this is used for testing and it should be fine even in case of integer overflow.
TouchedAt: FirstCreatedAt.Add(time.Duration(24) * time.Hour),
func (f *userUtxoFixture) Stored() *database.UserUTXO {
utxo := &database.UserUTXO{
UserID: f.userID,
TxID: f.txID,
Vout: f.vout,
Satoshis: uint64(f.satoshis),
EstimatedInputSize: f.estimatedInputSize,
Bucket: string(bucket.BSV),
CreatedAt: FirstCreatedAt.Add(time.Duration(f.index) * time.Second), //nolint:gosec // this is used for testing and it should be fine even in case of integer overflow.
TouchedAt: FirstCreatedAt.Add(time.Duration(24) * time.Hour),
}

f.db.Create(utxo)
Expand Down
6 changes: 3 additions & 3 deletions engine/database/tracked_transaction.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ type TrackedTransaction struct {
Inputs []*TrackedOutput `gorm:"foreignKey:SpendingTX"`
Outputs []*TrackedOutput `gorm:"foreignKey:TxID"`

newUTXOs []*UserUtxos `gorm:"-"`
newUTXOs []*UserUTXO `gorm:"-"`
}

// CreateP2PKHOutput prepares a new P2PKH output and adds it to the transaction.
Expand Down Expand Up @@ -52,7 +52,7 @@ func (t *TrackedTransaction) AddInputs(inputs ...*TrackedOutput) {
func (t *TrackedTransaction) AfterCreate(tx *gorm.DB) error {
// Add new UTXOs
if len(t.newUTXOs) > 0 {
err := tx.Model(&UserUtxos{}).Create(t.newUTXOs).Error
err := tx.Model(&UserUTXO{}).Create(t.newUTXOs).Error
if err != nil {
return spverrors.Wrapf(err, "failed to save user utxos")
}
Expand All @@ -67,7 +67,7 @@ func (t *TrackedTransaction) AfterCreate(tx *gorm.DB) error {
}
})
if len(spentOutpoints) > 0 {
err := tx.Where("(tx_id, vout) IN ?", spentOutpoints).Delete(&UserUtxos{}).Error
err := tx.Where("(tx_id, vout) IN ?", spentOutpoints).Delete(&UserUTXO{}).Error
if err != nil {
return spverrors.Wrapf(err, "failed to delete spent utxos")
}
Expand Down
54 changes: 32 additions & 22 deletions engine/database/user_utxos.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,29 +6,39 @@ import (
"gorm.io/datatypes"
)

// UserUtxos is a table holding user's Unspent Transaction Outputs (UTXOs).
// TODO: It should be renamed to UserUTXO.
type UserUtxos struct {
UserID string `gorm:"primaryKey;uniqueIndex:idx_window,sort:asc,priority:1"`
TxID string `gorm:"primaryKey;uniqueIndex:idx_window,sort:asc,priority:4"`
Vout uint32 `gorm:"primaryKey;uniqueIndex:idx_window,sort:asc,priority:5"`
Satoshis uint64
UnlockingScriptEstimatedSize uint64
Bucket string `gorm:"check:chk_not_data_bucket,bucket <> 'data'"`
CreatedAt time.Time `gorm:"uniqueIndex:idx_window,sort:asc,priority:3"`
TouchedAt time.Time `gorm:"uniqueIndex:idx_window,sort:asc,priority:2"`
CustomInstructions datatypes.JSONSlice[CustomInstruction]
// EstimatedInputSizeForP2PKH is the estimated size increase when adding and unlocking P2PKH input to transaction.
// 32 bytes txID
// + 4 bytes vout index
// + 1 byte script length
// + 107 bytes script pub key
// + 4 bytes nSequence
const EstimatedInputSizeForP2PKH = 148

// UserUTXO is a table holding user's Unspent Transaction Outputs (UTXOs).
type UserUTXO struct {
UserID string `gorm:"primaryKey;uniqueIndex:idx_window,sort:asc,priority:1"`
TxID string `gorm:"primaryKey;uniqueIndex:idx_window,sort:asc,priority:4"`
Vout uint32 `gorm:"primaryKey;uniqueIndex:idx_window,sort:asc,priority:5"`
Satoshis uint64
// EstimatedInputSize is the estimated size increase when adding and unlocking this UTXO to a transaction.
EstimatedInputSize uint64
Bucket string `gorm:"check:chk_not_data_bucket,bucket <> 'data'"`
CreatedAt time.Time `gorm:"uniqueIndex:idx_window,sort:asc,priority:3"`
// TouchedAt is the time when the UTXO was last touched (selected for preparing transaction outline) - used for prioritizing UTXO selection.
TouchedAt time.Time `gorm:"uniqueIndex:idx_window,sort:asc,priority:2"`
// CustomInstructions is the list of instructions for unlocking given UTXO (it should be understood by client).
CustomInstructions datatypes.JSONSlice[CustomInstruction]
}

// NewP2PKHUserUTXO creates a new UserUtxos instance for a P2PKH output based on the given output and custom instructions.
func NewP2PKHUserUTXO(output *TrackedOutput, customInstructions datatypes.JSONSlice[CustomInstruction]) *UserUtxos {
return &UserUtxos{
UserID: output.UserID,
TxID: output.TxID,
Vout: output.Vout,
Satoshis: uint64(output.Satoshis),
UnlockingScriptEstimatedSize: 106,
Bucket: "bsv",
CustomInstructions: customInstructions,
// NewP2PKHUserUTXO creates a new UserUTXO instance for a P2PKH output based on the given output and custom instructions.
func NewP2PKHUserUTXO(output *TrackedOutput, customInstructions datatypes.JSONSlice[CustomInstruction]) *UserUTXO {
return &UserUTXO{
UserID: output.UserID,
TxID: output.TxID,
Vout: output.Vout,
Satoshis: uint64(output.Satoshis),
EstimatedInputSize: EstimatedInputSizeForP2PKH,
Bucket: "bsv",
CustomInstructions: customInstructions,
}
}
3 changes: 0 additions & 3 deletions engine/tester/fixtures/tx_const_fixtures.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,3 @@ var DefaultFeeUnit = bsv.FeeUnit{
Satoshis: 1,
Bytes: 1000,
}

// EstimatedUnlockingScriptSizeForP2PKH is the estimated unlocking script size for a P2PKH transaction.
const EstimatedUnlockingScriptSizeForP2PKH = 106
3 changes: 2 additions & 1 deletion engine/tester/fixtures/tx_fixtures_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@ func TestMockTXGeneration(t *testing.T) {
spec := test.spec

// when
ok, err := spv.VerifyScripts(spec.TX())
tx := spec.TX()
ok, err := spv.VerifyScripts(tx)

// then:
require.NoError(t, err)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,12 @@ func (c *inputsQueryComposer) build(db *gorm.DB) *gorm.DB {
utxoWithMinChange := c.searchForMinimalChangeValue(db, utxoWithChange)
selectedOutpoints := c.chooseInputsToCoverOutputsAndFeesAndHaveMinimalChange(db, utxoWithMinChange)

res := db.Model(&database.UserUtxos{}).Where("(tx_id, vout) in (?)", selectedOutpoints)
res := db.Model(&database.UserUTXO{}).Where("(tx_id, vout) in (?)", selectedOutpoints)
return res
}

func (c *inputsQueryComposer) utxos(db *gorm.DB) *gorm.DB {
return db.Model(&database.UserUtxos{}).
return db.Model(&database.UserUTXO{}).
Select(
txIdColumn,
voutColumn,
Expand Down Expand Up @@ -60,11 +60,11 @@ func (c *inputsQueryComposer) searchForMinimalChangeValue(db *gorm.DB, utxoWithC
}

func (c *inputsQueryComposer) feeCalculatedWithChangeOutput() string {
return fmt.Sprintf("ceil((sum(unlocking_script_estimated_size) over (order by touched_at ASC, created_at ASC, tx_id ASC, vout ASC) + %d + %d) / cast(%d as float)) * %d as fee_with_change_output", c.txWithoutInputsSize, estimatedChangeOutputSize, c.feeUnit.Bytes, c.feeUnit.Satoshis)
return fmt.Sprintf("ceil((sum(estimated_input_size) over (order by touched_at ASC, created_at ASC, tx_id ASC, vout ASC) + %d + %d) / cast(%d as float)) * %d as fee_with_change_output", c.txWithoutInputsSize, estimatedChangeOutputSize, c.feeUnit.Bytes, c.feeUnit.Satoshis)
}

func (c *inputsQueryComposer) feeCalculatedWithoutChangeOutput() string {
return fmt.Sprintf("ceil((sum(unlocking_script_estimated_size) over (order by touched_at ASC, created_at ASC, tx_id ASC, vout ASC) + %d) / cast(%d as float)) * %d as fee_no_change_output", c.txWithoutInputsSize, c.feeUnit.Bytes, c.feeUnit.Satoshis)
return fmt.Sprintf("ceil((sum(estimated_input_size) over (order by touched_at ASC, created_at ASC, tx_id ASC, vout ASC) + %d) / cast(%d as float)) * %d as fee_no_change_output", c.txWithoutInputsSize, c.feeUnit.Bytes, c.feeUnit.Satoshis)
}

func (c *inputsQueryComposer) remainingValue() string {
Expand Down
8 changes: 4 additions & 4 deletions engine/transaction/outlines/internal/inputs/selector.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ const voutColumn = "vout"

// Selector is a service that selects inputs for transaction.
type Selector interface {
SelectInputsForTransaction(ctx context.Context, userID string, satoshis bsv.Satoshis, byteSizeOfTxBeforeAddingSelectedInputs uint64) ([]*database.UserUtxos, error)
SelectInputsForTransaction(ctx context.Context, userID string, satoshis bsv.Satoshis, byteSizeOfTxBeforeAddingSelectedInputs uint64) ([]*database.UserUTXO, error)
}

const (
Expand All @@ -39,7 +39,7 @@ func NewSelector(db *gorm.DB, feeUnit bsv.FeeUnit) Selector {
}
}

func (r *sqlInputsSelector) SelectInputsForTransaction(ctx context.Context, userID string, outputsTotalValue bsv.Satoshis, byteSizeOfTxWithoutInputs uint64) (utxos []*database.UserUtxos, err error) {
func (r *sqlInputsSelector) SelectInputsForTransaction(ctx context.Context, userID string, outputsTotalValue bsv.Satoshis, byteSizeOfTxWithoutInputs uint64) (utxos []*database.UserUTXO, err error) {
err = r.db.WithContext(ctx).Transaction(func(db *gorm.DB) error {
inputsQuery := r.buildQueryForInputs(db, userID, outputsTotalValue, byteSizeOfTxWithoutInputs)

Expand Down Expand Up @@ -78,10 +78,10 @@ func (r *sqlInputsSelector) buildQueryForInputs(db *gorm.DB, userID string, outp
return composer.build(db)
}

func (r *sqlInputsSelector) buildUpdateTouchedAtQuery(db *gorm.DB, utxos []*database.UserUtxos) *gorm.DB {
func (r *sqlInputsSelector) buildUpdateTouchedAtQuery(db *gorm.DB, utxos []*database.UserUTXO) *gorm.DB {
outpoints := make([][]any, 0, len(utxos))
for _, utxo := range utxos {
outpoints = append(outpoints, []any{utxo.TxID, utxo.Vout})
}
return db.Model(&database.UserUtxos{}).Where("(tx_id, vout) in (?)", outpoints)
return db.Model(&database.UserUTXO{}).Where("(tx_id, vout) in (?)", outpoints)
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,13 @@ func ExampleSelector_buildQueryForInputs_sqlite() {

query := db.ToSQL(func(db *gorm.DB) *gorm.DB {
query := selector.buildQueryForInputs(db, "someuserid", 1, 10)
query.Find(&database.UserUtxos{})
query.Find(&database.UserUTXO{})
return query
})

fmt.Println(query)

// Output: SELECT * FROM `xapi_user_utxos` WHERE (tx_id, vout) in (SELECT tx_id,vout FROM (SELECT tx_id,vout,change,min(case when change >= 0 then change end) over () as min_change FROM (SELECT tx_id,vout,case when remaining_value - fee_no_change_output <= 0 then remaining_value - fee_no_change_output else remaining_value - fee_with_change_output end as change FROM (SELECT `tx_id`,`vout`,sum(satoshis) over (order by touched_at ASC, created_at ASC, tx_id ASC, vout ASC) - 1 as remaining_value,ceil((sum(unlocking_script_estimated_size) over (order by touched_at ASC, created_at ASC, tx_id ASC, vout ASC) + 10) / cast(1000 as float)) * 1 as fee_no_change_output,ceil((sum(unlocking_script_estimated_size) over (order by touched_at ASC, created_at ASC, tx_id ASC, vout ASC) + 10 + 34) / cast(1000 as float)) * 1 as fee_with_change_output FROM `xapi_user_utxos` WHERE user_id = "someuserid") as utxo) as utxoWithChange) as utxoWithMinChange WHERE change <= min_change)
// Output: SELECT * FROM `xapi_user_utxos` WHERE (tx_id, vout) in (SELECT tx_id,vout FROM (SELECT tx_id,vout,change,min(case when change >= 0 then change end) over () as min_change FROM (SELECT tx_id,vout,case when remaining_value - fee_no_change_output <= 0 then remaining_value - fee_no_change_output else remaining_value - fee_with_change_output end as change FROM (SELECT `tx_id`,`vout`,sum(satoshis) over (order by touched_at ASC, created_at ASC, tx_id ASC, vout ASC) - 1 as remaining_value,ceil((sum(estimated_input_size) over (order by touched_at ASC, created_at ASC, tx_id ASC, vout ASC) + 10) / cast(1000 as float)) * 1 as fee_no_change_output,ceil((sum(estimated_input_size) over (order by touched_at ASC, created_at ASC, tx_id ASC, vout ASC) + 10 + 34) / cast(1000 as float)) * 1 as fee_with_change_output FROM `xapi_user_utxos` WHERE user_id = "someuserid") as utxo) as utxoWithChange) as utxoWithMinChange WHERE change <= min_change)
}

// ExampleSelector_buildQueryForInputs_postgresql demonstrates what would be the query used to select inputs for a transaction.
Expand All @@ -37,13 +37,13 @@ func ExampleSelector_buildQueryForInputs_postgresql() {

query := db.ToSQL(func(db *gorm.DB) *gorm.DB {
query := selector.buildQueryForInputs(db, "someuserid", 1, 10)
query.Find(&database.UserUtxos{})
query.Find(&database.UserUTXO{})
return query
})

fmt.Println(query)

// Output: SELECT * FROM "xapi_user_utxos" WHERE (tx_id, vout) in (SELECT tx_id,vout FROM (SELECT tx_id,vout,change,min(case when change >= 0 then change end) over () as min_change FROM (SELECT tx_id,vout,case when remaining_value - fee_no_change_output <= 0 then remaining_value - fee_no_change_output else remaining_value - fee_with_change_output end as change FROM (SELECT "tx_id","vout",sum(satoshis) over (order by touched_at ASC, created_at ASC, tx_id ASC, vout ASC) - 1 as remaining_value,ceil((sum(unlocking_script_estimated_size) over (order by touched_at ASC, created_at ASC, tx_id ASC, vout ASC) + 10) / cast(1000 as float)) * 1 as fee_no_change_output,ceil((sum(unlocking_script_estimated_size) over (order by touched_at ASC, created_at ASC, tx_id ASC, vout ASC) + 10 + 34) / cast(1000 as float)) * 1 as fee_with_change_output FROM "xapi_user_utxos" WHERE user_id = 'someuserid') as utxo) as utxoWithChange) as utxoWithMinChange WHERE change <= min_change)
// Output: SELECT * FROM "xapi_user_utxos" WHERE (tx_id, vout) in (SELECT tx_id,vout FROM (SELECT tx_id,vout,change,min(case when change >= 0 then change end) over () as min_change FROM (SELECT tx_id,vout,case when remaining_value - fee_no_change_output <= 0 then remaining_value - fee_no_change_output else remaining_value - fee_with_change_output end as change FROM (SELECT "tx_id","vout",sum(satoshis) over (order by touched_at ASC, created_at ASC, tx_id ASC, vout ASC) - 1 as remaining_value,ceil((sum(estimated_input_size) over (order by touched_at ASC, created_at ASC, tx_id ASC, vout ASC) + 10) / cast(1000 as float)) * 1 as fee_no_change_output,ceil((sum(estimated_input_size) over (order by touched_at ASC, created_at ASC, tx_id ASC, vout ASC) + 10 + 34) / cast(1000 as float)) * 1 as fee_with_change_output FROM "xapi_user_utxos" WHERE user_id = 'someuserid') as utxo) as utxoWithChange) as utxoWithMinChange WHERE change <= min_change)
}

// ExampleSelector_buildUpdateTouchedAtQuery_sqlite demonstrates what would be the SQL statement used to update inputs after selecting them.
Expand All @@ -52,10 +52,10 @@ func ExampleSelector_buildUpdateTouchedAtQuery_sqlite() {

selector := givenInputsSelector(db)

utxos := []*database.UserUtxos{
{UserID: "id_of_user_1", TxID: "tx_id_1", Vout: 0, Satoshis: 10, UnlockingScriptEstimatedSize: 106, Bucket: "bsv", CreatedAt: time.Now(), TouchedAt: time.Now()},
{UserID: "id_of_user_1", TxID: "tx_id_1", Vout: 1, Satoshis: 10, UnlockingScriptEstimatedSize: 106, Bucket: "bsv", CreatedAt: time.Now(), TouchedAt: time.Now()},
{UserID: "id_of_user_1", TxID: "tx_id_2", Vout: 0, Satoshis: 10, UnlockingScriptEstimatedSize: 106, Bucket: "bsv", CreatedAt: time.Now(), TouchedAt: time.Now()},
utxos := []*database.UserUTXO{
{UserID: "id_of_user_1", TxID: "tx_id_1", Vout: 0, Satoshis: 10, EstimatedInputSize: 148, Bucket: "bsv", CreatedAt: time.Now(), TouchedAt: time.Now()},
{UserID: "id_of_user_1", TxID: "tx_id_1", Vout: 1, Satoshis: 10, EstimatedInputSize: 148, Bucket: "bsv", CreatedAt: time.Now(), TouchedAt: time.Now()},
{UserID: "id_of_user_1", TxID: "tx_id_2", Vout: 0, Satoshis: 10, EstimatedInputSize: 148, Bucket: "bsv", CreatedAt: time.Now(), TouchedAt: time.Now()},
}

query := db.ToSQL(func(db *gorm.DB) *gorm.DB {
Expand All @@ -75,10 +75,10 @@ func ExampleSelector_buildUpdateTouchedAtQuery_postgres() {

selector := givenInputsSelector(db)

utxos := []*database.UserUtxos{
{UserID: "id_of_user_1", TxID: "tx_id_1", Vout: 0, Satoshis: 10, UnlockingScriptEstimatedSize: 106, Bucket: "bsv", CreatedAt: time.Now(), TouchedAt: time.Now()},
{UserID: "id_of_user_1", TxID: "tx_id_1", Vout: 1, Satoshis: 10, UnlockingScriptEstimatedSize: 106, Bucket: "bsv", CreatedAt: time.Now(), TouchedAt: time.Now()},
{UserID: "id_of_user_1", TxID: "tx_id_2", Vout: 0, Satoshis: 10, UnlockingScriptEstimatedSize: 106, Bucket: "bsv", CreatedAt: time.Now(), TouchedAt: time.Now()},
utxos := []*database.UserUTXO{
{UserID: "id_of_user_1", TxID: "tx_id_1", Vout: 0, Satoshis: 10, EstimatedInputSize: 148, Bucket: "bsv", CreatedAt: time.Now(), TouchedAt: time.Now()},
{UserID: "id_of_user_1", TxID: "tx_id_1", Vout: 1, Satoshis: 10, EstimatedInputSize: 148, Bucket: "bsv", CreatedAt: time.Now(), TouchedAt: time.Now()},
{UserID: "id_of_user_1", TxID: "tx_id_2", Vout: 0, Satoshis: 10, EstimatedInputSize: 148, Bucket: "bsv", CreatedAt: time.Now(), TouchedAt: time.Now()},
}

query := db.ToSQL(func(db *gorm.DB) *gorm.DB {
Expand Down
Loading

0 comments on commit d4c9702

Please sign in to comment.