Skip to content

Commit

Permalink
Merge pull request #21 from hyperledger/feature/tx-simulation
Browse files Browse the repository at this point in the history
DA-536 Check transaction validity on the preparation step
  • Loading branch information
nguyer authored Nov 6, 2023
2 parents c8b86f6 + b3a589d commit 2976b01
Show file tree
Hide file tree
Showing 6 changed files with 250 additions and 122 deletions.
5 changes: 5 additions & 0 deletions internal/tezos/error_mapping.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ type tezosRPCMethodCategory int

const (
blockRPCMethods tezosRPCMethodCategory = iota
callRPCMethods
sendRPCMethods
)

Expand All @@ -26,6 +27,10 @@ func mapError(methodType tezosRPCMethodCategory, err error) ffcapi.ErrorReason {
if strings.Contains(errString, "status 404") {
return ffcapi.ErrorReasonNotFound
}
case callRPCMethods:
if strings.Contains(errString, "script_rejected") {
return ffcapi.ErrorReasonTransactionReverted
}
case sendRPCMethods:
if strings.Contains(errString, "counter_in_the_past") {
return ffcapi.ErrorReasonNonceTooLow
Expand Down
15 changes: 15 additions & 0 deletions internal/tezos/exec_query.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package tezos
import (
"context"

"blockwatch.cc/tzgo/codec"
"blockwatch.cc/tzgo/rpc"
"github.com/hyperledger/firefly-transaction-manager/pkg/ffcapi"
)

Expand All @@ -11,3 +13,16 @@ func (c *tezosConnector) QueryInvoke(_ context.Context, req *ffcapi.QueryInvokeR
// TODO: to implement
return nil, "", nil
}

func (c *tezosConnector) callTransaction(ctx context.Context, op *codec.Op, opts *rpc.CallOptions) (*rpc.Receipt, ffcapi.ErrorReason, error) {
sim, err := c.client.Simulate(ctx, op, opts)
if err != nil {
return nil, mapError(callRPCMethods, err), err
}
// fail with Tezos error when simulation failed
if !sim.IsSuccess() {
return nil, mapError(callRPCMethods, sim.Error()), sim.Error()
}

return sim, "", nil
}
45 changes: 44 additions & 1 deletion internal/tezos/prepare_transaction.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,13 @@ import (
"blockwatch.cc/tzgo/tezos"
"github.com/hyperledger/firefly-common/pkg/fftypes"
"github.com/hyperledger/firefly-common/pkg/i18n"
"github.com/hyperledger/firefly-common/pkg/log"
"github.com/hyperledger/firefly-tezosconnect/internal/msgs"
"github.com/hyperledger/firefly-transaction-manager/pkg/ffcapi"
)

// TransactionPrepare validates transaction inputs against the supplied schema/Michelson and performs any binary serialization required (prior to signing) to encode a transaction from JSON into the native blockchain format
func (c *tezosConnector) TransactionPrepare(ctx context.Context, req *ffcapi.TransactionPrepareRequest) (*ffcapi.TransactionPrepareResponse, ffcapi.ErrorReason, error) {
func (c *tezosConnector) TransactionPrepare(ctx context.Context, req *ffcapi.TransactionPrepareRequest) (res *ffcapi.TransactionPrepareResponse, reason ffcapi.ErrorReason, err error) {
params, err := c.prepareInputParams(ctx, &req.TransactionInput)
if err != nil {
return nil, ffcapi.ErrorReasonInvalidInputs, err
Expand All @@ -32,12 +33,54 @@ func (c *tezosConnector) TransactionPrepare(ctx context.Context, req *ffcapi.Tra
return nil, "", err
}

opts := &rpc.DefaultOptions
if reason, err = c.estimateAndAssignTxCost(ctx, op, opts); err != nil {
return nil, reason, err
}
log.L(ctx).Infof("Prepared transaction method=%s dataLen=%d", req.Method.String(), len(op.Bytes()))

return &ffcapi.TransactionPrepareResponse{
Gas: req.Gas,
TransactionData: hex.EncodeToString(op.Bytes()),
}, "", nil
}

func (c *tezosConnector) estimateAndAssignTxCost(ctx context.Context, op *codec.Op, opts *rpc.CallOptions) (ffcapi.ErrorReason, error) {
// Simulate the transaction (dry run)
sim, reason, err := c.callTransaction(ctx, op, nil)
if err != nil {
return reason, err
}

// apply simulated cost as limits to tx list
if !opts.IgnoreLimits {
op.WithLimits(sim.MinLimits(), rpc.ExtraSafetyMargin)
}

// log info about tx costs
costs := sim.Costs()
for i, v := range op.Contents {
verb := "used"
if opts.IgnoreLimits {
verb = "forced"
}
limits := v.Limits()
log.L(ctx).Debugf("OP#%03d: %s gas_used(sim)=%d storage_used(sim)=%d storage_burn(sim)=%d alloc_burn(sim)=%d fee(%s)=%d gas_limit(%s)=%d storage_limit(%s)=%d ",
i, v.Kind(), costs[i].GasUsed, costs[i].StorageUsed, costs[i].StorageBurn, costs[i].AllocationBurn,
verb, limits.Fee, verb, limits.GasLimit, verb, limits.StorageLimit,
)
}

// check minFee calc against maxFee if set
if opts.MaxFee > 0 {
if l := op.Limits(); l.Fee > opts.MaxFee {
return "", fmt.Errorf("estimated cost %d > max %d", l.Fee, opts.MaxFee)
}
}

return "", nil
}

func (c *tezosConnector) prepareInputParams(ctx context.Context, req *ffcapi.TransactionInput) (micheline.Parameters, error) {
var tezosParams micheline.Parameters

Expand Down
185 changes: 185 additions & 0 deletions internal/tezos/prepare_transaction_test.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
package tezos

import (
"encoding/json"
"errors"
"testing"

"blockwatch.cc/tzgo/codec"
"blockwatch.cc/tzgo/contract"
"blockwatch.cc/tzgo/rpc"
"blockwatch.cc/tzgo/tezos"
"github.com/hyperledger/firefly-common/pkg/fftypes"
Expand All @@ -25,6 +28,25 @@ func TestTransactionPrepareOk(t *testing.T) {
Manager: "edpkv89Jj4aVWetK69CWm5ss1LayvK8dQoiFz7p995y1k3E8CZwqJ6",
}, nil)

mRPC.On("Simulate", ctx, mock.Anything, mock.Anything).
Return(&rpc.Receipt{
Op: &rpc.Operation{
Contents: []rpc.TypedOperation{
rpc.Transaction{
Manager: rpc.Manager{
Generic: rpc.Generic{
Metadata: rpc.OperationMetadata{
Result: rpc.OperationResult{
Status: tezos.OpStatusApplied,
},
},
},
},
},
},
},
}, nil)

req := &ffcapi.TransactionPrepareRequest{
TransactionInput: ffcapi.TransactionInput{
TransactionHeaders: ffcapi.TransactionHeaders{
Expand Down Expand Up @@ -133,3 +155,166 @@ func TestTransactionPrepareGetContractExtError(t *testing.T) {
_, _, err := c.TransactionPrepare(ctx, req)
assert.Error(t, err)
}

func TestTransactionPrepareSimulateError(t *testing.T) {
ctx, c, mRPC, done := newTestConnector(t)
defer done()

mRPC.On("GetBlockHash", ctx, mock.Anything).
Return(tezos.NewBlockHash([]byte("BMBeYrMJpLWrqCs7UTcFaUQCeWBqsjCLejX5D8zE8m9syHqHnZg")), nil)

mRPC.On("GetContractExt", ctx, mock.Anything, mock.Anything).
Return(&rpc.ContractInfo{
Counter: 10,
Manager: "edpkv89Jj4aVWetK69CWm5ss1LayvK8dQoiFz7p995y1k3E8CZwqJ6",
}, nil)

mRPC.On("Simulate", ctx, mock.Anything, mock.Anything).Return(nil, errors.New("error"))

req := &ffcapi.TransactionPrepareRequest{
TransactionInput: ffcapi.TransactionInput{
TransactionHeaders: ffcapi.TransactionHeaders{
From: "tz1Y6GnVhC4EpcDDSmD3ibcC4WX6DJ4Q1QLN",
To: "KT1D254HTPKq5GZNVcF73XBinG9BLybHqu8s",
},
Method: fftypes.JSONAnyPtr("\"pause\""),
Params: []*fftypes.JSONAny{
fftypes.JSONAnyPtr("{\"entrypoint\":\"pause\",\"value\":{\"prim\":\"True\"}}"),
},
},
}

_, _, err := c.TransactionPrepare(ctx, req)
assert.Error(t, err)
}

func TestTransactionPrepareWrongSimulateStatusError(t *testing.T) {
ctx, c, mRPC, done := newTestConnector(t)
defer done()

mRPC.On("GetBlockHash", ctx, mock.Anything).
Return(tezos.NewBlockHash([]byte("BMBeYrMJpLWrqCs7UTcFaUQCeWBqsjCLejX5D8zE8m9syHqHnZg")), nil)

mRPC.On("GetContractExt", ctx, mock.Anything, mock.Anything).
Return(&rpc.ContractInfo{
Counter: 10,
Manager: "edpkv89Jj4aVWetK69CWm5ss1LayvK8dQoiFz7p995y1k3E8CZwqJ6",
}, nil)

mRPC.On("Simulate", ctx, mock.Anything, mock.Anything).
Return(&rpc.Receipt{
Op: &rpc.Operation{
Contents: []rpc.TypedOperation{
rpc.Transaction{
Manager: rpc.Manager{
Generic: rpc.Generic{
Metadata: rpc.OperationMetadata{
Result: rpc.OperationResult{
Errors: []rpc.OperationError{
{
GenericError: rpc.GenericError{
ID: "error id: script_rejected",
Kind: "error: script_rejected",
},
Raw: json.RawMessage{},
},
},
},
},
},
},
},
},
},
}, nil)

req := &ffcapi.TransactionPrepareRequest{
TransactionInput: ffcapi.TransactionInput{
TransactionHeaders: ffcapi.TransactionHeaders{
From: "tz1Y6GnVhC4EpcDDSmD3ibcC4WX6DJ4Q1QLN",
To: "KT1D254HTPKq5GZNVcF73XBinG9BLybHqu8s",
},
Method: fftypes.JSONAnyPtr("\"pause\""),
Params: []*fftypes.JSONAny{
fftypes.JSONAnyPtr("{\"entrypoint\":\"pause\",\"value\":{\"prim\":\"True\"}}"),
},
},
}

_, reason, err := c.TransactionPrepare(ctx, req)
assert.Error(t, err)
assert.Equal(t, reason, ffcapi.ErrorReasonTransactionReverted)
}

func Test_estimateAndAssignTxCostIgnoreLimitsOk(t *testing.T) {
ctx, c, mRPC, done := newTestConnector(t)
defer done()

mRPC.On("Simulate", ctx, mock.Anything, mock.Anything).
Return(&rpc.Receipt{
Op: &rpc.Operation{
Contents: []rpc.TypedOperation{
rpc.Transaction{
Manager: rpc.Manager{
Generic: rpc.Generic{
Metadata: rpc.OperationMetadata{
Result: rpc.OperationResult{
Status: tezos.OpStatusApplied,
},
},
},
},
},
},
},
}, nil)

op := codec.NewOp()
txArgs := contract.TxArgs{}
op.WithContents(txArgs.Encode())

opts := &rpc.DefaultOptions
opts.IgnoreLimits = true

_, err := c.estimateAndAssignTxCost(ctx, op, opts)
assert.NoError(t, err)
}

func Test_estimateAndAssignExceedMaxLimitError(t *testing.T) {
ctx, c, mRPC, done := newTestConnector(t)
defer done()

mRPC.On("Simulate", ctx, mock.Anything, mock.Anything).
Return(&rpc.Receipt{
Op: &rpc.Operation{
Contents: []rpc.TypedOperation{
rpc.Transaction{
Manager: rpc.Manager{
Generic: rpc.Generic{
Metadata: rpc.OperationMetadata{
Result: rpc.OperationResult{
Status: tezos.OpStatusApplied,
},
},
},
},
},
},
},
}, nil)

op := codec.NewOp()
txArgs := contract.TxArgs{}
op.WithContents(txArgs.Encode())
op.WithLimits([]tezos.Limits{
{
Fee: 100,
},
}, 100)

opts := &rpc.DefaultOptions
opts.MaxFee = 1

_, err := c.estimateAndAssignTxCost(ctx, op, opts)
assert.Error(t, err)
}
43 changes: 1 addition & 42 deletions internal/tezos/send_transaction.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,7 @@ import (
"net/http"

"blockwatch.cc/tzgo/codec"
"blockwatch.cc/tzgo/rpc"
"blockwatch.cc/tzgo/tezos"
"github.com/hyperledger/firefly-common/pkg/log"
"github.com/hyperledger/firefly-transaction-manager/pkg/ffcapi"
)

Expand All @@ -34,44 +32,6 @@ func (c *tezosConnector) TransactionSend(ctx context.Context, req *ffcapi.Transa
return nil, "", err
}

opts := &rpc.DefaultOptions

// simulate to check tx validity and estimate cost
sim, err := c.client.Simulate(ctx, op, opts)
if err != nil {
return nil, mapError(sendRPCMethods, err), err
}
// fail with Tezos error when simulation failed
if !sim.IsSuccess() {
return nil, "", sim.Error()
}

// apply simulated cost as limits to tx list
if !opts.IgnoreLimits {
op.WithLimits(sim.MinLimits(), rpc.ExtraSafetyMargin)
}

// log info about tx costs
costs := sim.Costs()
for i, v := range op.Contents {
verb := "used"
if opts.IgnoreLimits {
verb = "forced"
}
limits := v.Limits()
log.L(ctx).Debugf("OP#%03d: %s gas_used(sim)=%d storage_used(sim)=%d storage_burn(sim)=%d alloc_burn(sim)=%d fee(%s)=%d gas_limit(%s)=%d storage_limit(%s)=%d ",
i, v.Kind(), costs[i].GasUsed, costs[i].StorageUsed, costs[i].StorageBurn, costs[i].AllocationBurn,
verb, limits.Fee, verb, limits.GasLimit, verb, limits.StorageLimit,
)
}

// check minFee calc against maxFee if set
if opts.MaxFee > 0 {
if l := op.Limits(); l.Fee > opts.MaxFee {
return nil, "", fmt.Errorf("estimated cost %d > max %d", l.Fee, opts.MaxFee)
}
}

// sign tx
err = c.signTxRemotely(ctx, op)
if err != nil {
Expand All @@ -89,8 +49,7 @@ func (c *tezosConnector) TransactionSend(ctx context.Context, req *ffcapi.Transa
}, "", nil
}

// nolint:unparam
func (c *tezosConnector) signTxRemotely(ctx context.Context, op *codec.Op) error {
func (c *tezosConnector) signTxRemotely(_ context.Context, op *codec.Op) error {
url := c.signatoryURL + "/keys/" + op.Source.String()
requestBody, _ := json.Marshal(hex.EncodeToString(op.WatermarkedBytes()))

Expand Down
Loading

0 comments on commit 2976b01

Please sign in to comment.