Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions thorclient/bind/bind_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (
"github.com/vechain/thor/v2/test/testnode"
"github.com/vechain/thor/v2/thor"
"github.com/vechain/thor/v2/thorclient"
"github.com/vechain/thor/v2/tx"
)

// testEnv holds the test environment including chain and contract
Expand Down Expand Up @@ -152,6 +153,56 @@ func TestContract_Send(t *testing.T) {
})
}

func TestContract_SubmitTxConstruction(t *testing.T) {
env := setupTestEnv(t)
defer env.testNode.Stop()

// The test node mints a block only when a tx arrives, so the best block read
// here is the one Submit builds on.
bestBaseFee := func() *big.Int {
best, err := env.client.Block("best")
require.NoError(t, err)
require.NotNil(t, best.BaseFeePerGas, "test chain must be past GALACTICA")
return (*big.Int)(best.BaseFeePerGas)
}
send := func(opts *TxOptions) *tx.Transaction {
trx, err := env.bindContract.Method("setValue", big.NewInt(7)).
Send().
WithSigner(env.owner).
WithOptions(opts).
Submit()
require.NoError(t, err)
return trx
}

t.Run("dynamic fee tx with base fee headroom", func(t *testing.T) {
baseFee := bestBaseFee()
trx := send(nil)

assert.Equal(t, tx.TypeDynamicFee, trx.Type())
assert.Equal(t, new(big.Int).Lsh(baseFee, 1), trx.MaxFeePerGas())
})

t.Run("default max fee covers the priority fee", func(t *testing.T) {
baseFee := bestBaseFee()
tip := big.NewInt(1_000)
trx := send(&TxOptions{MaxPriorityFeePerGas: tip})

want := new(big.Int).Add(new(big.Int).Lsh(baseFee, 1), tip)
assert.Equal(t, want, trx.MaxFeePerGas())
assert.Equal(t, tip, trx.MaxPriorityFeePerGas())
})

t.Run("defaults do not leak into the caller's options", func(t *testing.T) {
opts := &TxOptions{}
first := send(opts)
second := send(opts)

assert.Equal(t, TxOptions{}, *opts)
assert.NotEqual(t, first.ID(), second.ID())
})
}

func TestContract_Filter(t *testing.T) {
env := setupTestEnv(t)
defer env.testNode.Stop()
Expand Down
24 changes: 9 additions & 15 deletions thorclient/bind/contract.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,31 +50,25 @@ func DeployContract(client *thorclient.Client, signer Signer, abiData []byte, by
if err != nil {
return nil, err
}
clause := tx.NewClause(nil).WithData(bc)

tag, err := client.ChainTag()
// Unlike Submit, a deployment whose simulation fails is not sent.
gas, simulation, err := estimateGas(client, signer.Address(), clause)
if err != nil {
return nil, err
return nil, fmt.Errorf("failed to estimate deployment gas: %w", err)
}
if simulation.Reverted || simulation.VMError != "" {
return nil, fmt.Errorf("unable to deploy contract: %+v", simulation)
}

trx := tx.NewBuilder(tx.TypeLegacy).
ChainTag(tag).
Clause(tx.NewClause(nil).WithData(bc)).
Expiration(10000).
Gas(10_000_000).
Build()
trx, err = signer.SignTransaction(trx)
trx, err := buildTx(client, clause, TxOptions{Gas: &gas, Expiration: new(uint32(10000))})
if err != nil {
return nil, err
}

signerAddr := signer.Address()
clauses, err := client.InspectTxClauses(trx, &signerAddr)
trx, err = signer.SignTransaction(trx)
if err != nil {
return nil, err
}
if len(clauses) != 1 || clauses[0].Reverted || clauses[0].VMError != "" {
return nil, fmt.Errorf("unable to deploy contract: %+v", clauses)
}

res, err := client.SendTransaction(trx)
if err != nil {
Expand Down
109 changes: 56 additions & 53 deletions thorclient/bind/send.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,6 @@ import (
"math/big"
"time"

"github.com/ethereum/go-ethereum/common/hexutil"
"github.com/ethereum/go-ethereum/common/math"

"github.com/vechain/thor/v2/api"
"github.com/vechain/thor/v2/test/datagen"
"github.com/vechain/thor/v2/thor"
Expand Down Expand Up @@ -71,12 +68,46 @@ func (b *SendBuilder) Submit() (*tx.Transaction, error) {
return nil, err
}

// Build the transaction
best, err := b.op.contract.client.Block("best")
// Copy the options so that the defaults set below never leak into the
// caller's TxOptions, and a reused builder gets a fresh nonce and blockRef.
var opts TxOptions
if b.opts != nil {
opts = *b.opts
}

if opts.Gas == nil {
gas, _, err := estimateGas(b.op.contract.client, b.signer.Address(), clause)
if err != nil {
return nil, fmt.Errorf("failed to estimate gas (%s): %w", b.op.String(), err)
}
opts.Gas = &gas
}

transaction, err := buildTx(b.op.contract.client, clause, opts)
if err != nil {
return nil, err
}
transaction, err = b.signer.SignTransaction(transaction)
if err != nil {
return nil, fmt.Errorf("failed to sign transaction: %w", err)
}

if _, err = b.op.contract.client.SendTransaction(transaction); err != nil {
return nil, fmt.Errorf("failed to send transaction (%s): %w", b.op.String(), err)
}

return transaction, nil
}

// buildTx builds an unsigned tx for clause on top of the best block. opts.Gas
// must be set. Every other unset option gets a default. The tx is dynamic-fee
// when the best block has a base fee, and legacy otherwise.
func buildTx(client *thorclient.Client, clause *tx.Clause, opts TxOptions) (*tx.Transaction, error) {
best, err := client.Block("best")
if err != nil {
return nil, fmt.Errorf("failed to get best block: %w", err)
}
chainTag, err := b.op.contract.client.ChainTag()
chainTag, err := client.ChainTag()
if err != nil {
return nil, fmt.Errorf("failed to get chain tag: %w", err)
}
Expand All @@ -86,37 +117,6 @@ func (b *SendBuilder) Submit() (*tx.Transaction, error) {
txType = tx.TypeDynamicFee
}

opts := b.opts
if opts == nil {
opts = &TxOptions{}
}

// Calculate gas if not provided
if opts.Gas == nil {
gas, err := tx.IntrinsicGas(clause)
if err != nil {
return nil, fmt.Errorf("failed to calculate intrinsic gas: %w", err)
}
caller := b.signer.Address()
simulation, err := b.op.contract.client.InspectClauses(
&api.BatchCallData{
Caller: &caller,
Clauses: api.Clauses{{To: b.op.contract.addr, Data: hexutil.Encode(clause.Data()), Value: (*math.HexOrDecimal256)(clause.Value())}},
}, thorclient.Revision("best"))
if err != nil {
return nil, fmt.Errorf("failed to simulate clauses (%s): %w", b.op.String(), err)
}
if len(simulation) != 1 {
return nil, fmt.Errorf("expected 1 simulation result, got %d", len(simulation))
}
gas += simulation[0].GasUsed
if clause.To() != nil && len(clause.Data()) > 0 {
gas += 15_000 // buffer required for OP_CALL
}
opts.Gas = &gas
}

// Set default options if not provided
if opts.Expiration == nil {
expiration := uint32(60) // 60 blocks ~10 minutes
opts.Expiration = &expiration
Expand All @@ -129,12 +129,8 @@ func (b *SendBuilder) Submit() (*tx.Transaction, error) {
nonce := datagen.RandUint64()
opts.Nonce = &nonce
}
if txType == tx.TypeDynamicFee && opts.MaxFeePerGas == nil {
opts.MaxFeePerGas = (*big.Int)(best.BaseFeePerGas)
}

// Build the transaction
builder := new(tx.Builder).
builder := tx.NewBuilder(txType).
Comment thread
libotony marked this conversation as resolved.
Resolved
Clause(clause).
Gas(*opts.Gas).
ChainTag(chainTag).
Expand All @@ -147,23 +143,30 @@ func (b *SendBuilder) Submit() (*tx.Transaction, error) {
case tx.TypeLegacy:
builder.GasPriceCoef(0)
case tx.TypeDynamicFee:
builder.MaxFeePerGas(opts.MaxFeePerGas)
maxFee := opts.MaxFeePerGas
if maxFee == nil {
maxFee = defaultMaxFeePerGas((*big.Int)(best.BaseFeePerGas), opts.MaxPriorityFeePerGas)
}
builder.MaxFeePerGas(maxFee)
if opts.MaxPriorityFeePerGas != nil {
builder.MaxPriorityFeePerGas(opts.MaxPriorityFeePerGas)
}
}

transaction := builder.Build()
transaction, err = b.signer.SignTransaction(transaction)
if err != nil {
return nil, fmt.Errorf("failed to sign transaction: %w", err)
}

if _, err = b.op.contract.client.SendTransaction(transaction); err != nil {
return nil, fmt.Errorf("failed to send transaction (%s): %w", b.op.String(), err)
}
return builder.Build(), nil
}

return transaction, nil
// defaultMaxFeePerGas returns 2*baseFee + tip. The txpool and the packer price a
// tx against the next block's base fee, which can be up to 12.5% higher per
// block. Doubling the base fee covers several increases, and adding the tip
// keeps it fully payable. The tx pays min(maxFee, baseFee+tip), so the extra
// headroom costs nothing.
func defaultMaxFeePerGas(baseFee, tip *big.Int) *big.Int {
maxFee := new(big.Int).Lsh(baseFee, 1)
if tip != nil {
maxFee.Add(maxFee, tip)
}
return maxFee
}

// SubmitAndConfirm implements SendBuilder.Receipt.
Expand Down
64 changes: 64 additions & 0 deletions thorclient/bind/simulate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// Copyright (c) 2025 The VeChainThor developers
//
// Distributed under the GNU Lesser General Public License v3.0 software license, see the accompanying
// file LICENSE or <https://www.gnu.org/licenses/lgpl-3.0.html>

package bind

import (
"fmt"

"github.com/ethereum/go-ethereum/common/hexutil"
"github.com/ethereum/go-ethereum/common/math"

"github.com/vechain/thor/v2/api"
"github.com/vechain/thor/v2/thor"
"github.com/vechain/thor/v2/thorclient"
"github.com/vechain/thor/v2/tx"
)

// simulateClause simulates the execution of a single clause on top of the best block
// and returns the simulation result.
func simulateClause(client *thorclient.Client, caller thor.Address, clause *tx.Clause) (*api.CallResult, error) {
var to *thor.Address
if clause.To() != nil {
addr := *clause.To()
to = &addr
}
sim, err := client.InspectClauses(&api.BatchCallData{
Caller: &caller,
Clauses: api.Clauses{{
To: to,
Data: hexutil.Encode(clause.Data()),
Value: (*math.HexOrDecimal256)(clause.Value()),
}},
}, thorclient.Revision("best"))
if err != nil {
return nil, err
}
if len(sim) != 1 {
return nil, fmt.Errorf("expected 1 simulation result, got %d", len(sim))
}
return sim[0], nil
}

// estimateGas returns the gas limit for clause: intrinsic gas, plus the simulated
// execution gas, plus a margin when the clause runs code. The simulation runs with
// a large gas budget, but under an exact limit each nested CALL/CREATE keeps back
// 1/64 of the remaining gas (EIP-150) and can run out. The margin is 20% of the
// execution gas (about 11 nested levels), and at least 15k.
func estimateGas(client *thorclient.Client, caller thor.Address, clause *tx.Clause) (uint64, *api.CallResult, error) {
gas, err := tx.IntrinsicGas(clause)
if err != nil {
return 0, nil, fmt.Errorf("failed to calculate intrinsic gas: %w", err)
}
sim, err := simulateClause(client, caller, clause)
if err != nil {
return 0, nil, fmt.Errorf("failed to simulate clause: %w", err)
}
gas += sim.GasUsed
if clause.To() == nil || len(clause.Data()) > 0 {
gas += max(sim.GasUsed/5, 15_000)
}
return gas, sim, nil
}
2 changes: 1 addition & 1 deletion thorclient/thorclient.go
Original file line number Diff line number Diff line change
Expand Up @@ -991,7 +991,7 @@ func (c *Client) Peers() ([]*api.PeerStats, error) {
// }
//
// // Use chain tag when building transaction
// tx := tx.NewBuilder(chainTag).
// tx := tx.NewBuilder(tx.TypeDynamicFee).
// ChainTag(chainTag).
// BlockRef(blockRef).
// // ... other transaction fields
Expand Down
Loading