diff --git a/op-challenger/game/fault/contracts/faultdisputegame.go b/op-challenger/game/fault/contracts/faultdisputegame.go index e48f06549dfb..61e3a09674f9 100644 --- a/op-challenger/game/fault/contracts/faultdisputegame.go +++ b/op-challenger/game/fault/contracts/faultdisputegame.go @@ -54,6 +54,7 @@ var ( methodL2BlockNumberChallenged = "l2BlockNumberChallenged" methodL2BlockNumberChallenger = "l2BlockNumberChallenger" methodChallengeRootL2Block = "challengeRootL2Block" + subClaimField = "_claim" ) var ( @@ -624,4 +625,5 @@ type FaultDisputeGameContract interface { ResolveClaimTx(claimIdx uint64) (txmgr.TxCandidate, error) CallResolve(ctx context.Context) (gameTypes.GameStatus, error) ResolveTx() (txmgr.TxCandidate, error) + GetSubClaims(ctx context.Context, block rpcblock.Block, aggClaim *types.Claim) ([]common.Hash, error) } diff --git a/op-challenger/game/fault/contracts/faultdisputegame2.go b/op-challenger/game/fault/contracts/faultdisputegame2.go new file mode 100644 index 000000000000..e447eba9e615 --- /dev/null +++ b/op-challenger/game/fault/contracts/faultdisputegame2.go @@ -0,0 +1,75 @@ +package contracts + +import ( + "context" + "fmt" + "math/big" + + "github.com/ethereum-optimism/optimism/op-challenger/game/fault/types" + "github.com/ethereum-optimism/optimism/op-e2e/bindings" + "github.com/ethereum-optimism/optimism/op-service/sources/batching" + "github.com/ethereum-optimism/optimism/op-service/sources/batching/rpcblock" + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/rpc" +) + +func (f *FaultDisputeGameContractLatest) GetSubClaims(ctx context.Context, block rpcblock.Block, aggClaim *types.Claim) ([]common.Hash, error) { + defer f.metrics.StartContractRequest("GetAllSubClaims")() + + filter, err := bindings.NewFaultDisputeGameFilterer(f.contract.Addr(), f.multiCaller) + if err != nil { + return nil, err + } + + parentIndex := [...]*big.Int{big.NewInt(int64(aggClaim.ParentContractIndex))} + claim := [...][32]byte{aggClaim.ClaimData.ValueBytes()} + claimant := [...]common.Address{aggClaim.Claimant} + var end *uint64 + if block.ArgValue() == rpcblock.Latest { + end = nil + } else { + blockNumber, ok := block.ArgValue().(rpc.BlockNumber) + if !ok { + return nil, fmt.Errorf("block number is not lastest or int64") + } + blockNumberU64 := uint64(blockNumber) + end = &blockNumberU64 + } + moveIter, err := filter.FilterMove(&bind.FilterOpts{End: end, Context: ctx}, parentIndex[:], claim[:], claimant[:]) + if err != nil { + return nil, fmt.Errorf("failed to filter move event log: %w", err) + } + ok := moveIter.Next() + if !ok { + return nil, fmt.Errorf("failed to get move event log: %w", moveIter.Error()) + } + txHash := moveIter.Event.Raw.TxHash + + // todo: replace hardcoded method name + txCall := batching.NewTxGetByHash(f.contract.Abi(), txHash, "move") + result, err := f.multiCaller.SingleCall(ctx, block, txCall) + if err != nil { + return nil, fmt.Errorf("failed to load claim calldata: %w", err) + } + + txn := result.GetTx() + + var subClaims []common.Hash + + if len(txn.BlobHashes()) > 0 { + // todo: fetch Blobs and unpack it into subClaims + return nil, fmt.Errorf("blob tx hasn't been supported") + } else { + inputMap, err := txCall.UnpackCallData(&txn) + if err != nil { + return nil, fmt.Errorf("failed to unpack tx resp: %w", err) + } + // todo: replace claim with nary-subclaims + claim := *abi.ConvertType(inputMap[subClaimField], new([32]byte)).(*[32]byte) + subClaims = append(subClaims, claim) + } + + return subClaims, nil +} diff --git a/op-challenger/game/fault/contracts/faultdisputegame_test.go b/op-challenger/game/fault/contracts/faultdisputegame_test.go index 4ea7d9c3d0bf..877ff31820cf 100644 --- a/op-challenger/game/fault/contracts/faultdisputegame_test.go +++ b/op-challenger/game/fault/contracts/faultdisputegame_test.go @@ -23,6 +23,7 @@ import ( "github.com/ethereum-optimism/optimism/packages/contracts-bedrock/snapshots" "github.com/ethereum/go-ethereum/accounts/abi" "github.com/ethereum/go-ethereum/common" + coreTypes "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/rlp" "github.com/stretchr/testify/require" ) @@ -321,6 +322,82 @@ func TestGetAllClaims(t *testing.T) { } } +func TestGetSubClaims(t *testing.T) { + for _, version := range versions { + // todo: backward and forward support + if version.Is("1.2.0") { + version := version + t.Run(version.version, func(t *testing.T) { + stubRpc, game := setupFaultDisputeGameTest(t, version) + claim0 := faultTypes.Claim{ + ClaimData: faultTypes.ClaimData{ + Value: common.Hash{0xaa}, + Position: faultTypes.NewPositionFromGIndex(big.NewInt(1)), + Bond: big.NewInt(5), + }, + CounteredBy: common.Address{0x01}, + Claimant: common.Address{0x02}, + Clock: decodeClock(big.NewInt(1234)), + ContractIndex: 0, + ParentContractIndex: math.MaxUint32, + } + expectedClaims := []faultTypes.Claim{claim0} + block := rpcblock.ByNumber(42) + stubRpc.SetResponse(fdgAddr, methodClaimCount, block, nil, []interface{}{big.NewInt(int64(len(expectedClaims)))}) + + eventName := "Move" + fdgAbi := version.loadAbi() + + var challgenIndex []interface{} + challgenIndex = append(challgenIndex, big.NewInt(int64(claim0.ParentContractIndex))) + claim := []interface{}{claim0.ClaimData.ValueBytes()} + address := []interface{}{claim0.Claimant} + query := [][]interface{}{challgenIndex, claim, address} + txHash := common.Hash{0xff} + + query = append([][]interface{}{{fdgAbi.Events[eventName].ID}}, query...) + + topics, err := abi.MakeTopics(query...) + var queryTopics []common.Hash + for _, item := range topics { + queryTopics = append(queryTopics, item[0]) + } + require.NoError(t, err) + out := []coreTypes.Log{ + { + Address: fdgAddr, + Topics: queryTopics, + Data: []byte{}, + TxHash: txHash, + }, + } + stubRpc.SetFilterLogResponse(topics, fdgAddr, block, out) + + contractCall := batching.NewContractCall(fdgAbi, fdgAddr, "move", claim0.ClaimData.Value, challgenIndex[0], claim0.ClaimData.Value, true) + inputData, err := contractCall.Pack() + require.NoError(t, err) + + tx := coreTypes.NewTx(&coreTypes.LegacyTx{ + Nonce: 0, + GasPrice: big.NewInt(11111), + Gas: 1111, + To: &claim0.Claimant, + Value: big.NewInt(111), + Data: inputData, + }) + packed, err := tx.MarshalBinary() + require.NoError(t, err) + stubRpc.SetGetTxByHashResponse(txHash, packed) + + claims, err := game.GetSubClaims(context.Background(), block, &claim0) + require.NoError(t, err) + require.Equal(t, 1, len(claims)) + require.Equal(t, claim0.ClaimData.Value, claims[0]) + }) + } + } +} + func TestGetBalance(t *testing.T) { for _, version := range versions { version := version diff --git a/op-service/sources/batching/bound.go b/op-service/sources/batching/bound.go index 33e19c3dfbfa..e3aff050f130 100644 --- a/op-service/sources/batching/bound.go +++ b/op-service/sources/batching/bound.go @@ -32,6 +32,10 @@ func (b *BoundContract) Addr() common.Address { return b.addr } +func (b *BoundContract) Abi() *abi.ABI { + return b.abi +} + func (b *BoundContract) Call(method string, args ...interface{}) *ContractCall { return NewContractCall(b.abi, b.addr, method, args...) } diff --git a/op-service/sources/batching/call.go b/op-service/sources/batching/call.go index db4563b64258..6784d8ba04cb 100644 --- a/op-service/sources/batching/call.go +++ b/op-service/sources/batching/call.go @@ -6,6 +6,7 @@ import ( "github.com/ethereum-optimism/optimism/op-service/sources/batching/rpcblock" "github.com/ethereum/go-ethereum/accounts/abi" "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/rpc" ) @@ -67,3 +68,7 @@ func (c *CallResult) GetBytes32Slice(i int) [][32]byte { func (c *CallResult) GetString(i int) string { return *abi.ConvertType(c.out[i], new(string)).(*string) } + +func (c *CallResult) GetTx() types.Transaction { + return *abi.ConvertType(c.out[0], new(types.Transaction)).(*types.Transaction) +} diff --git a/op-service/sources/batching/event_call.go b/op-service/sources/batching/event_call.go new file mode 100644 index 000000000000..01e09b9fc359 --- /dev/null +++ b/op-service/sources/batching/event_call.go @@ -0,0 +1,37 @@ +package batching + +import ( + "github.com/ethereum-optimism/optimism/op-service/sources/batching/rpcblock" + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/rpc" +) + +type EventCall struct { + topics [][]common.Hash + to []common.Address +} + +func NewEventCall(q ethereum.FilterQuery) *EventCall { + return &EventCall{ + topics: q.Topics, + to: q.Addresses, + } +} + +func (b *EventCall) ToBatchElemCreator() (BatchElementCreator, error) { + return func(block rpcblock.Block) (any, rpc.BatchElem) { + out := new([]types.Log) + return out, rpc.BatchElem{ + Method: "eth_getFilterLogs", + Args: []interface{}{b.topics, b.to[0], block.ArgValue()}, + Result: &out, + } + }, nil +} + +func (c *EventCall) HandleResult(result interface{}) (*CallResult, error) { + res := result.(*[]types.Log) + return &CallResult{out: []interface{}{*res}}, nil +} diff --git a/op-service/sources/batching/event_call_test.go b/op-service/sources/batching/event_call_test.go new file mode 100644 index 000000000000..6144e34bd402 --- /dev/null +++ b/op-service/sources/batching/event_call_test.go @@ -0,0 +1,75 @@ +package batching + +import ( + "math/big" + "testing" + + "github.com/ethereum-optimism/optimism/op-service/sources/batching/rpcblock" + batchingTest "github.com/ethereum-optimism/optimism/op-service/sources/batching/test" + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/require" +) + +func TestEventLogFilter(t *testing.T) { + addr := common.Address{0xbd} + stub := batchingTest.NewRpcStub(t) + owner := []common.Address{{0xaa}} + spender := []common.Address{{0xbb}} + + testAbi, err := batchingTest.ERC20MetaData.GetAbi() + require.NoError(t, err) + name := "Approval" + var ownerRule []interface{} + for _, ownerItem := range owner { + ownerRule = append(ownerRule, ownerItem) + } + var spenderRule []interface{} + for _, spenderItem := range spender { + spenderRule = append(spenderRule, spenderItem) + } + query := [][]interface{}{ownerRule, spenderRule} + query = append([][]interface{}{{testAbi.Events[name].ID}}, query...) + + topics, err := abi.MakeTopics(query...) + require.NoError(t, err) + + txHash := common.Hash{0x11} + block := rpcblock.Latest + require.NoError(t, err) + event := testAbi.Events[name] + inputs := event.Inputs + + amount := big.NewInt(3) + packedData, err := inputs.NonIndexed().Pack(amount) + require.NoError(t, err) + _out := []types.Log{ + { + Address: addr, + Topics: []common.Hash{topics[0][0], topics[1][0], topics[2][0]}, + Data: packedData, + TxHash: txHash, + }, + } + out := make([]interface{}, len(_out)) + for i, r := range _out { + out[i] = r + } + + stub.SetFilterLogResponse(topics, addr, block, _out) + caller := NewMultiCaller(stub, DefaultBatchSize) + + filter, err := batchingTest.NewERC20Filterer(addr, caller) + require.NoError(t, err) + + iterator, err := filter.FilterApproval(nil, owner, spender) + require.NoError(t, err) + + res := iterator.Next() + require.True(t, res, iterator.Error()) + require.Equal(t, _out[0].Address, iterator.Event.Raw.Address) + require.Equal(t, _out[0].Topics, iterator.Event.Raw.Topics) + require.Equal(t, _out[0].Data, iterator.Event.Raw.Data) + require.Equal(t, txHash, iterator.Event.Raw.TxHash) +} diff --git a/op-service/sources/batching/multicall.go b/op-service/sources/batching/multicall.go index 2a02ce774f73..88b500791c7a 100644 --- a/op-service/sources/batching/multicall.go +++ b/op-service/sources/batching/multicall.go @@ -2,10 +2,13 @@ package batching import ( "context" + "errors" "fmt" "io" "github.com/ethereum-optimism/optimism/op-service/sources/batching/rpcblock" + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/rpc" ) @@ -80,3 +83,20 @@ func (m *MultiCaller) Call(ctx context.Context, block rpcblock.Block, calls ...C } return callResults, nil } + +// implment LogFilterer interface +func (m *MultiCaller) FilterLogs(ctx context.Context, q ethereum.FilterQuery) ([]types.Log, error) { + call := NewEventCall(q) + results, err := m.SingleCall(ctx, rpcblock.ByNumber(q.FromBlock.Uint64()), call) + if err != nil { + return nil, err + } + var out []types.Log + results.GetStruct(0, &out) + return out, nil +} + +// implment LogFilterer interface +func (m *MultiCaller) SubscribeFilterLogs(ctx context.Context, q ethereum.FilterQuery, ch chan<- types.Log) (ethereum.Subscription, error) { + return nil, errors.New("unimplemented") +} diff --git a/op-service/sources/batching/test/abi_stub.go b/op-service/sources/batching/test/abi_stub.go index 3c834896a800..2a67fe2738a7 100644 --- a/op-service/sources/batching/test/abi_stub.go +++ b/op-service/sources/batching/test/abi_stub.go @@ -12,6 +12,7 @@ import ( "github.com/ethereum/go-ethereum/accounts/abi" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core/types" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -127,6 +128,7 @@ func (l *AbiBasedRpc) SetError(to common.Address, method string, block rpcblock. err: callErr, }) } + func (l *AbiBasedRpc) SetResponse(to common.Address, method string, block rpcblock.Block, expected []interface{}, output []interface{}) { if expected == nil { expected = []interface{}{} @@ -148,6 +150,26 @@ func (l *AbiBasedRpc) SetResponse(to common.Address, method string, block rpcblo }) } +func (l *AbiBasedRpc) SetFilterLogResponse(topics [][]common.Hash, to common.Address, block rpcblock.Block, output []types.Log) { + if output == nil { + output = []types.Log{} + } + + l.AddExpectedCall(&expectedFilterLogsCall{ + topics: topics, + to: to, + block: block, + outputs: output, + }) +} + +func (l *AbiBasedRpc) SetGetTxByHashResponse(txHash common.Hash, output []byte) { + if output == nil { + output = []byte{} + } + l.AddExpectedCall(&expectedGetTxByHashCall{txHash: txHash, outputs: output}) +} + func (l *AbiBasedRpc) VerifyTxCandidate(candidate txmgr.TxCandidate) { require.NotNil(l.t, candidate.To) l.findExpectedCall("eth_call", map[string]any{ diff --git a/op-service/sources/batching/test/event_stub.go b/op-service/sources/batching/test/event_stub.go new file mode 100644 index 000000000000..6b46d385f819 --- /dev/null +++ b/op-service/sources/batching/test/event_stub.go @@ -0,0 +1,66 @@ +package test + +import ( + "encoding/json" + "fmt" + "reflect" + "testing" + + "github.com/ethereum-optimism/optimism/op-service/sources/batching/rpcblock" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/require" +) + +type expectedFilterLogsCall struct { + topics [][]common.Hash + to common.Address + block rpcblock.Block + outputs []types.Log + err error +} + +func (c *expectedFilterLogsCall) Matches(rpcMethod string, args ...interface{}) error { + if rpcMethod != "eth_getFilterLogs" { + return fmt.Errorf("expected rpcMethod eth_getFilterLogs but was %v", rpcMethod) + } + + topics, ok := args[0].([][]common.Hash) + if !ok { + return fmt.Errorf("arg 0 is not [][]common.Hash") + } + + if !reflect.DeepEqual(topics, c.topics) { + return fmt.Errorf("expected topics %v but was %v", c.topics, topics) + } + + to := args[1].(common.Address) + if to != c.to { + return fmt.Errorf("expected contract address %v but was %v", c.to, to) + } + return c.err +} + +func (c *expectedFilterLogsCall) Execute(t *testing.T, out interface{}) error { + j, err := json.Marshal(c.outputs) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(j, out)) + return c.err +} + +func (c *expectedFilterLogsCall) String() string { + return fmt.Sprintf("{to: %v, block: %v, outputs: %v}", c.to, c.block, c.outputs) +} + +func (l *RpcStub) SetFilterLogResponse(topics [][]common.Hash, to common.Address, block rpcblock.Block, output []types.Log) { + if output == nil { + output = []types.Log{} + } + + l.AddExpectedCall(&expectedFilterLogsCall{ + topics: topics, + to: to, + block: block, + outputs: output, + }) +} diff --git a/op-service/sources/batching/test/generic_stub.go b/op-service/sources/batching/test/generic_stub.go index c269eb100d6b..d79d121d4363 100644 --- a/op-service/sources/batching/test/generic_stub.go +++ b/op-service/sources/batching/test/generic_stub.go @@ -80,6 +80,14 @@ func NewGetBalanceCall(addr common.Address, block rpcblock.Block, balance *big.I } } +func NewGetTxCall(txHash common.Hash, block rpcblock.Block, out *[]byte) ExpectedRpcCall { + return &GenericExpectedCall{ + method: "eth_getTransactionByHash", + args: []interface{}{txHash, block.ArgValue()}, + result: hexutil.Encode(*out), + } +} + func (c *GenericExpectedCall) Matches(rpcMethod string, args ...interface{}) error { if rpcMethod != c.method { return fmt.Errorf("expected method %v but was %v", c.method, rpcMethod) diff --git a/op-service/sources/batching/test/tx_stub.go b/op-service/sources/batching/test/tx_stub.go new file mode 100644 index 000000000000..4b14a7592781 --- /dev/null +++ b/op-service/sources/batching/test/tx_stub.go @@ -0,0 +1,45 @@ +package test + +import ( + "encoding/json" + "fmt" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/stretchr/testify/require" +) + +type expectedGetTxByHashCall struct { + txHash common.Hash + outputs []byte + err error +} + +func (c *expectedGetTxByHashCall) Matches(rpcMethod string, args ...interface{}) error { + if rpcMethod != "eth_getTransactionByHash" { + return fmt.Errorf("expected rpcMethod eth_getTransactionByHash but was %v", rpcMethod) + } + + txhash, ok := args[0].(common.Hash) + if !ok { + return fmt.Errorf("arg 0 is not common.Hash") + } + + if txhash != c.txHash { + return fmt.Errorf("expected txHash %v but was %v", c.txHash, txhash) + } + + return c.err +} + +func (c *expectedGetTxByHashCall) Execute(t *testing.T, out interface{}) error { + j, err := json.Marshal(hexutil.Bytes(c.outputs)) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(j, out)) + return c.err +} + +func (c *expectedGetTxByHashCall) String() string { + return fmt.Sprintf("{txHash: %v, outputs: %v}", c.txHash, c.outputs) +} diff --git a/op-service/sources/batching/tx_call.go b/op-service/sources/batching/tx_call.go new file mode 100644 index 000000000000..de3d223e2e5d --- /dev/null +++ b/op-service/sources/batching/tx_call.go @@ -0,0 +1,64 @@ +package batching + +import ( + "fmt" + + "github.com/ethereum-optimism/optimism/op-service/sources/batching/rpcblock" + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/rpc" +) + +type TxGetByHashCall struct { + Abi *abi.ABI + TxHash common.Hash + Method string +} + +func NewTxGetByHash(abi *abi.ABI, txhash common.Hash, method string) *TxGetByHashCall { + return &TxGetByHashCall{ + Abi: abi, + TxHash: txhash, + Method: method, + } +} + +func (b *TxGetByHashCall) ToBatchElemCreator() (BatchElementCreator, error) { + return func(block rpcblock.Block) (any, rpc.BatchElem) { + out := new(hexutil.Bytes) + return out, rpc.BatchElem{ + Method: "eth_getTransactionByHash", + Args: []interface{}{b.TxHash, block.ArgValue()}, + Result: &out, + } + }, nil +} + +func (c *TxGetByHashCall) HandleResult(result interface{}) (*CallResult, error) { + res, ok := result.(*hexutil.Bytes) + if !ok { + return nil, fmt.Errorf("result is not hexutil.Bytes") + } + + txn := new(types.Transaction) + err := txn.UnmarshalBinary(*res) + if err != nil { + return nil, err + } + return &CallResult{out: []interface{}{txn}}, nil +} + +func (c *TxGetByHashCall) UnpackCallData(txn *types.Transaction) (map[string]interface{}, error) { + data := txn.Data() + m, err := c.Abi.MethodById(data[:4]) + v := map[string]interface{}{} + if err != nil { + return map[string]interface{}{}, err + } + if err := m.Inputs.UnpackIntoMap(v, data[4:]); err != nil { + return map[string]interface{}{}, err + } + return v, nil +} diff --git a/op-service/sources/batching/tx_call_test.go b/op-service/sources/batching/tx_call_test.go new file mode 100644 index 000000000000..7dfdefb88335 --- /dev/null +++ b/op-service/sources/batching/tx_call_test.go @@ -0,0 +1,50 @@ +package batching + +import ( + "context" + "math/big" + "testing" + + "github.com/ethereum-optimism/optimism/op-service/sources/batching/rpcblock" + "github.com/ethereum-optimism/optimism/op-service/sources/batching/test" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/require" +) + +func TestUnpackTxCalldata(t *testing.T) { + expectedSpender := common.Address{0xcc} + expectedAmount := big.NewInt(1234444) + txHash := common.Hash{0x11} + addr := common.Address{0xbd} + + testAbi, err := test.ERC20MetaData.GetAbi() + require.NoError(t, err) + contractCall := NewContractCall(testAbi, addr, "approve", expectedSpender, expectedAmount) + inputData, err := contractCall.Pack() + tx := types.NewTx(&types.LegacyTx{ + Nonce: 0, + GasPrice: big.NewInt(11111), + Gas: 1111, + To: &addr, + Value: big.NewInt(111), + Data: inputData, + }) + require.NoError(t, err) + packed, err := tx.MarshalBinary() + require.NoError(t, err) + + stub := test.NewRpcStub(t) + stub.AddExpectedCall(test.NewGetTxCall(txHash, rpcblock.Latest, &packed)) + + caller := NewMultiCaller(stub, DefaultBatchSize) + txCall := NewTxGetByHash(testAbi, txHash, "approve") + result, err := caller.SingleCall(context.Background(), rpcblock.Latest, txCall) + require.NoError(t, err) + + decodedTx := result.GetTx() + unpackedMap, err := txCall.UnpackCallData(&decodedTx) + require.NoError(t, err) + require.Equal(t, expectedSpender, unpackedMap["spender"]) + require.Equal(t, expectedAmount, unpackedMap["amount"]) +}