mirror of
https://github.com/ethereum/go-ethereum.git
synced 2026-02-26 07:37:20 +00:00
eth/tracers/native: add keccak256preimage tracer (#32569)
Introduces a new tracer which returns the preimages of evm KECCAK256 hashes. See #32570. --------- Co-authored-by: Sina M <1591639+s1na@users.noreply.github.com> Co-authored-by: Sina Mahmoodi <itz.s1na@gmail.com>
This commit is contained in:
parent
8e87b7539b
commit
c984d9086e
2 changed files with 528 additions and 0 deletions
86
eth/tracers/native/keccak256_preimage.go
Normal file
86
eth/tracers/native/keccak256_preimage.go
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
package native
|
||||
|
||||
// Copyright 2021 The go-ethereum Authors
|
||||
// This file is part of the go-ethereum library.
|
||||
//
|
||||
// The go-ethereum library is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Lesser General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// The go-ethereum library is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Lesser General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Lesser General Public License
|
||||
// along with the go-ethereum library. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/ethereum/go-ethereum/common"
|
||||
"github.com/ethereum/go-ethereum/common/hexutil"
|
||||
"github.com/ethereum/go-ethereum/core/tracing"
|
||||
"github.com/ethereum/go-ethereum/core/vm"
|
||||
"github.com/ethereum/go-ethereum/crypto"
|
||||
"github.com/ethereum/go-ethereum/eth/tracers"
|
||||
"github.com/ethereum/go-ethereum/eth/tracers/internal"
|
||||
"github.com/ethereum/go-ethereum/log"
|
||||
"github.com/ethereum/go-ethereum/params"
|
||||
)
|
||||
|
||||
func init() {
|
||||
tracers.DefaultDirectory.Register("keccak256PreimageTracer", newKeccak256PreimageTracer, false)
|
||||
}
|
||||
|
||||
// keccak256PreimageTracer is a native tracer that collects preimages of all KECCAK256 operations.
|
||||
// This tracer is particularly useful for analyzing smart contract execution patterns,
|
||||
// especially when debugging storage access in Solidity mappings and dynamic arrays.
|
||||
type keccak256PreimageTracer struct {
|
||||
computedHashes map[common.Hash]hexutil.Bytes
|
||||
}
|
||||
|
||||
// newKeccak256PreimageTracer returns a new keccak256PreimageTracer instance.
|
||||
func newKeccak256PreimageTracer(ctx *tracers.Context, cfg json.RawMessage, chainConfig *params.ChainConfig) (*tracers.Tracer, error) {
|
||||
t := &keccak256PreimageTracer{
|
||||
computedHashes: make(map[common.Hash]hexutil.Bytes),
|
||||
}
|
||||
return &tracers.Tracer{
|
||||
Hooks: &tracing.Hooks{
|
||||
OnOpcode: t.OnOpcode,
|
||||
},
|
||||
GetResult: t.GetResult,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (t *keccak256PreimageTracer) OnOpcode(pc uint64, op byte, gas, cost uint64, scope tracing.OpContext, rData []byte, depth int, err error) {
|
||||
if op == byte(vm.KECCAK256) {
|
||||
sd := scope.StackData()
|
||||
// it turns out that sometimes the stack is empty, evm will fail in this case, but we should not panic here
|
||||
if len(sd) < 2 {
|
||||
return
|
||||
}
|
||||
|
||||
dataOffset := internal.StackBack(sd, 0).Uint64()
|
||||
dataLength := internal.StackBack(sd, 1).Uint64()
|
||||
preimage, err := internal.GetMemoryCopyPadded(scope.MemoryData(), int64(dataOffset), int64(dataLength))
|
||||
if err != nil {
|
||||
log.Warn("keccak256PreimageTracer: failed to copy keccak preimage from memory", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
hash := crypto.Keccak256(preimage)
|
||||
|
||||
t.computedHashes[common.Hash(hash)] = hexutil.Bytes(preimage)
|
||||
}
|
||||
}
|
||||
|
||||
// GetResult returns the collected keccak256 preimages as a JSON object mapping hashes to preimages.
|
||||
func (t *keccak256PreimageTracer) GetResult() (json.RawMessage, error) {
|
||||
msg, err := json.Marshal(t.computedHashes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return msg, nil
|
||||
}
|
||||
442
eth/tracers/native/keccak256_preimage_test.go
Normal file
442
eth/tracers/native/keccak256_preimage_test.go
Normal file
|
|
@ -0,0 +1,442 @@
|
|||
// Copyright 2021 The go-ethereum Authors
|
||||
// This file is part of the go-ethereum library.
|
||||
//
|
||||
// The go-ethereum library is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Lesser General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// The go-ethereum library is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Lesser General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Lesser General Public License
|
||||
// along with the go-ethereum library. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package native_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/ethereum/go-ethereum/common"
|
||||
"github.com/ethereum/go-ethereum/common/hexutil"
|
||||
"github.com/ethereum/go-ethereum/core/tracing"
|
||||
"github.com/ethereum/go-ethereum/core/vm"
|
||||
"github.com/ethereum/go-ethereum/crypto"
|
||||
"github.com/ethereum/go-ethereum/eth/tracers"
|
||||
"github.com/ethereum/go-ethereum/params"
|
||||
"github.com/holiman/uint256"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// mockOpContext implements tracing.OpContext for testing
|
||||
type mockOpContext struct {
|
||||
memory []byte
|
||||
stack []uint256.Int
|
||||
}
|
||||
|
||||
// Ensure mockOpContext implements tracing.OpContext
|
||||
var _ tracing.OpContext = (*mockOpContext)(nil)
|
||||
|
||||
func (m *mockOpContext) MemoryData() []byte {
|
||||
return m.memory
|
||||
}
|
||||
|
||||
func (m *mockOpContext) StackData() []uint256.Int {
|
||||
return m.stack
|
||||
}
|
||||
|
||||
func (m *mockOpContext) Address() common.Address {
|
||||
return common.Address{}
|
||||
}
|
||||
|
||||
func (m *mockOpContext) Caller() common.Address {
|
||||
return common.Address{}
|
||||
}
|
||||
|
||||
func (m *mockOpContext) CallValue() *uint256.Int {
|
||||
return uint256.NewInt(0)
|
||||
}
|
||||
|
||||
func (m *mockOpContext) CallInput() []byte {
|
||||
return []byte{}
|
||||
}
|
||||
|
||||
func (m *mockOpContext) ContractCode() []byte {
|
||||
return []byte{}
|
||||
}
|
||||
|
||||
func TestKeccak256PreimageTracerCreation(t *testing.T) {
|
||||
tracer, err := tracers.DefaultDirectory.New("keccak256PreimageTracer", &tracers.Context{}, nil, params.MainnetChainConfig)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, tracer)
|
||||
require.NotNil(t, tracer.Hooks)
|
||||
require.NotNil(t, tracer.Hooks.OnOpcode)
|
||||
require.NotNil(t, tracer.GetResult)
|
||||
}
|
||||
|
||||
func TestKeccak256PreimageTracerInitialResult(t *testing.T) {
|
||||
tracer, err := tracers.DefaultDirectory.New("keccak256PreimageTracer", &tracers.Context{}, nil, params.MainnetChainConfig)
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := tracer.GetResult()
|
||||
require.NoError(t, err)
|
||||
|
||||
var hashes map[common.Hash]hexutil.Bytes
|
||||
err = json.Unmarshal(result, &hashes)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, hashes)
|
||||
}
|
||||
|
||||
func TestKeccak256PreimageTracerSingleKeccak(t *testing.T) {
|
||||
tracer, err := tracers.DefaultDirectory.New("keccak256PreimageTracer", &tracers.Context{}, nil, params.MainnetChainConfig)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Test data: "hello world"
|
||||
testData := []byte("hello world")
|
||||
memory := make([]byte, 32)
|
||||
copy(memory, testData)
|
||||
|
||||
// Create stack with offset=0, length=11
|
||||
stack := []uint256.Int{
|
||||
*uint256.NewInt(11), // length (stack[1])
|
||||
*uint256.NewInt(0), // offset (stack[0])
|
||||
}
|
||||
|
||||
mockScope := &mockOpContext{
|
||||
memory: memory,
|
||||
stack: stack,
|
||||
}
|
||||
|
||||
// Call OnOpcode with KECCAK256
|
||||
tracer.OnOpcode(0, byte(vm.KECCAK256), 0, 0, mockScope, nil, 0, nil)
|
||||
|
||||
// Get result
|
||||
result, err := tracer.GetResult()
|
||||
require.NoError(t, err)
|
||||
|
||||
var hashes map[common.Hash]hexutil.Bytes
|
||||
err = json.Unmarshal(result, &hashes)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify the hash and preimage
|
||||
expectedHash := crypto.Keccak256Hash(testData)
|
||||
require.Len(t, hashes, 1)
|
||||
require.Contains(t, hashes, expectedHash)
|
||||
require.Equal(t, hexutil.Bytes(testData), hashes[expectedHash])
|
||||
}
|
||||
|
||||
func TestKeccak256PreimageTracerMultipleKeccak(t *testing.T) {
|
||||
tracer, err := tracers.DefaultDirectory.New("keccak256PreimageTracer", &tracers.Context{}, nil, params.MainnetChainConfig)
|
||||
require.NoError(t, err)
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
data []byte
|
||||
}{
|
||||
{"empty", []byte{}},
|
||||
{"hello", []byte("hello")},
|
||||
{"world", []byte("world")},
|
||||
{"long_data", make([]byte, 100)},
|
||||
}
|
||||
|
||||
// Initialize long_data with some pattern
|
||||
for i := range testCases[3].data {
|
||||
testCases[3].data[i] = byte(i % 256)
|
||||
}
|
||||
|
||||
expectedHashes := make(map[common.Hash]hexutil.Bytes)
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
memory := make([]byte, max(len(tc.data), 1))
|
||||
copy(memory, tc.data)
|
||||
|
||||
stack := []uint256.Int{
|
||||
*uint256.NewInt(uint64(len(tc.data))), // length
|
||||
*uint256.NewInt(0), // offset
|
||||
}
|
||||
|
||||
mockScope := &mockOpContext{
|
||||
memory: memory,
|
||||
stack: stack,
|
||||
}
|
||||
|
||||
// Call OnOpcode with KECCAK256
|
||||
tracer.OnOpcode(0, byte(vm.KECCAK256), 0, 0, mockScope, nil, 0, nil)
|
||||
|
||||
expectedHash := crypto.Keccak256Hash(tc.data)
|
||||
expectedHashes[expectedHash] = hexutil.Bytes(tc.data)
|
||||
})
|
||||
}
|
||||
|
||||
// Get final result
|
||||
result, err := tracer.GetResult()
|
||||
require.NoError(t, err)
|
||||
|
||||
var hashes map[common.Hash]hexutil.Bytes
|
||||
err = json.Unmarshal(result, &hashes)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, expectedHashes, hashes)
|
||||
}
|
||||
|
||||
func TestKeccak256PreimageTracerNonKeccakOpcodes(t *testing.T) {
|
||||
tracer, err := tracers.DefaultDirectory.New("keccak256PreimageTracer", &tracers.Context{}, nil, params.MainnetChainConfig)
|
||||
require.NoError(t, err)
|
||||
|
||||
testData := []byte("should not be recorded")
|
||||
memory := make([]byte, 32)
|
||||
copy(memory, testData)
|
||||
|
||||
stack := []uint256.Int{
|
||||
*uint256.NewInt(uint64(len(testData))),
|
||||
*uint256.NewInt(0),
|
||||
}
|
||||
|
||||
mockScope := &mockOpContext{
|
||||
memory: memory,
|
||||
stack: stack,
|
||||
}
|
||||
|
||||
// Test various non-KECCAK256 opcodes
|
||||
nonKeccakOpcodes := []vm.OpCode{
|
||||
vm.ADD, vm.MUL, vm.SUB, vm.DIV, vm.SDIV, vm.MOD, vm.SMOD,
|
||||
vm.ADDMOD, vm.MULMOD, vm.EXP, vm.SIGNEXTEND, vm.SLOAD,
|
||||
vm.SSTORE, vm.MLOAD, vm.MSTORE, vm.CALL, vm.RETURN,
|
||||
}
|
||||
|
||||
for _, opcode := range nonKeccakOpcodes {
|
||||
tracer.OnOpcode(0, byte(opcode), 0, 0, mockScope, nil, 0, nil)
|
||||
}
|
||||
|
||||
// Get result - should be empty
|
||||
result, err := tracer.GetResult()
|
||||
require.NoError(t, err)
|
||||
|
||||
var hashes map[common.Hash]hexutil.Bytes
|
||||
err = json.Unmarshal(result, &hashes)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, hashes)
|
||||
}
|
||||
|
||||
func TestKeccak256PreimageTracerMemoryOffset(t *testing.T) {
|
||||
tracer, err := tracers.DefaultDirectory.New("keccak256PreimageTracer", &tracers.Context{}, nil, params.MainnetChainConfig)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Test data at different memory offset
|
||||
prefix := []byte("prefix_data_")
|
||||
testData := []byte("target_data")
|
||||
memory := make([]byte, len(prefix)+len(testData)+10)
|
||||
copy(memory, prefix)
|
||||
copy(memory[len(prefix):], testData)
|
||||
|
||||
// Stack: offset=len(prefix), length=len(testData)
|
||||
stack := []uint256.Int{
|
||||
*uint256.NewInt(uint64(len(testData))), // length
|
||||
*uint256.NewInt(uint64(len(prefix))), // offset
|
||||
}
|
||||
|
||||
mockScope := &mockOpContext{
|
||||
memory: memory,
|
||||
stack: stack,
|
||||
}
|
||||
|
||||
// Call OnOpcode with KECCAK256
|
||||
tracer.OnOpcode(0, byte(vm.KECCAK256), 0, 0, mockScope, nil, 0, nil)
|
||||
|
||||
// Get result
|
||||
result, err := tracer.GetResult()
|
||||
require.NoError(t, err)
|
||||
|
||||
var hashes map[common.Hash]hexutil.Bytes
|
||||
err = json.Unmarshal(result, &hashes)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify the hash matches the target data, not the prefix
|
||||
expectedHash := crypto.Keccak256Hash(testData)
|
||||
require.Len(t, hashes, 1)
|
||||
require.Contains(t, hashes, expectedHash)
|
||||
require.Equal(t, hexutil.Bytes(testData), hashes[expectedHash])
|
||||
}
|
||||
|
||||
func TestKeccak256PreimageTracerMemoryPadding(t *testing.T) {
|
||||
tracer, err := tracers.DefaultDirectory.New("keccak256PreimageTracer", &tracers.Context{}, nil, params.MainnetChainConfig)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Test data that extends beyond memory bounds (should be zero-padded)
|
||||
testData := []byte("short")
|
||||
memory := make([]byte, len(testData))
|
||||
copy(memory, testData)
|
||||
|
||||
// Request more data than available in memory
|
||||
requestedLength := len(testData) + 5
|
||||
stack := []uint256.Int{
|
||||
*uint256.NewInt(uint64(requestedLength)), // length > memory size
|
||||
*uint256.NewInt(0), // offset
|
||||
}
|
||||
|
||||
mockScope := &mockOpContext{
|
||||
memory: memory,
|
||||
stack: stack,
|
||||
}
|
||||
|
||||
// Call OnOpcode with KECCAK256
|
||||
tracer.OnOpcode(0, byte(vm.KECCAK256), 0, 0, mockScope, nil, 0, nil)
|
||||
|
||||
// Get result
|
||||
result, err := tracer.GetResult()
|
||||
require.NoError(t, err)
|
||||
|
||||
var hashes map[common.Hash]hexutil.Bytes
|
||||
err = json.Unmarshal(result, &hashes)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify the hash includes zero padding
|
||||
expectedData := make([]byte, requestedLength)
|
||||
copy(expectedData, testData)
|
||||
// Rest is zero-padded by default
|
||||
|
||||
expectedHash := crypto.Keccak256Hash(expectedData)
|
||||
require.Len(t, hashes, 1)
|
||||
require.Contains(t, hashes, expectedHash)
|
||||
require.Equal(t, hexutil.Bytes(expectedData), hashes[expectedHash])
|
||||
}
|
||||
|
||||
func TestKeccak256PreimageTracerDuplicateHashes(t *testing.T) {
|
||||
tracer, err := tracers.DefaultDirectory.New("keccak256PreimageTracer", &tracers.Context{}, nil, params.MainnetChainConfig)
|
||||
require.NoError(t, err)
|
||||
|
||||
testData := []byte("duplicate_test")
|
||||
memory := make([]byte, len(testData))
|
||||
copy(memory, testData)
|
||||
|
||||
stack := []uint256.Int{
|
||||
*uint256.NewInt(uint64(len(testData))),
|
||||
*uint256.NewInt(0),
|
||||
}
|
||||
|
||||
mockScope := &mockOpContext{
|
||||
memory: memory,
|
||||
stack: stack,
|
||||
}
|
||||
|
||||
// Call OnOpcode with KECCAK256 multiple times with same data
|
||||
for i := 0; i < 3; i++ {
|
||||
tracer.OnOpcode(0, byte(vm.KECCAK256), 0, 0, mockScope, nil, 0, nil)
|
||||
}
|
||||
|
||||
// Get result
|
||||
result, err := tracer.GetResult()
|
||||
require.NoError(t, err)
|
||||
|
||||
var hashes map[common.Hash]hexutil.Bytes
|
||||
err = json.Unmarshal(result, &hashes)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should only have one entry (duplicates overwrite)
|
||||
expectedHash := crypto.Keccak256Hash(testData)
|
||||
require.Len(t, hashes, 1)
|
||||
require.Contains(t, hashes, expectedHash)
|
||||
require.Equal(t, hexutil.Bytes(testData), hashes[expectedHash])
|
||||
}
|
||||
|
||||
func TestKeccak256PreimageTracerWithExecutionError(t *testing.T) {
|
||||
tracer, err := tracers.DefaultDirectory.New("keccak256PreimageTracer", &tracers.Context{}, nil, params.MainnetChainConfig)
|
||||
require.NoError(t, err)
|
||||
|
||||
testData := []byte("error_test")
|
||||
memory := make([]byte, len(testData))
|
||||
copy(memory, testData)
|
||||
|
||||
stack := []uint256.Int{
|
||||
*uint256.NewInt(uint64(len(testData))),
|
||||
*uint256.NewInt(0),
|
||||
}
|
||||
|
||||
mockScope := &mockOpContext{
|
||||
memory: memory,
|
||||
stack: stack,
|
||||
}
|
||||
|
||||
// Call OnOpcode with KECCAK256 and an execution error
|
||||
tracer.OnOpcode(0, byte(vm.KECCAK256), 0, 0, mockScope, nil, 0, vm.ErrOutOfGas)
|
||||
|
||||
// Get result - should still record the hash even with execution error
|
||||
result, err := tracer.GetResult()
|
||||
require.NoError(t, err)
|
||||
|
||||
var hashes map[common.Hash]hexutil.Bytes
|
||||
err = json.Unmarshal(result, &hashes)
|
||||
require.NoError(t, err)
|
||||
|
||||
expectedHash := crypto.Keccak256Hash(testData)
|
||||
require.Len(t, hashes, 1)
|
||||
require.Contains(t, hashes, expectedHash)
|
||||
require.Equal(t, hexutil.Bytes(testData), hashes[expectedHash])
|
||||
}
|
||||
|
||||
func TestKeccak256PreimageTracerInsufficientStack(t *testing.T) {
|
||||
tracer, err := tracers.DefaultDirectory.New("keccak256PreimageTracer", &tracers.Context{}, nil, params.MainnetChainConfig)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Test with insufficient stack items (should cause panic, but we test it doesn't crash)
|
||||
testData := []byte("test")
|
||||
memory := make([]byte, len(testData))
|
||||
copy(memory, testData)
|
||||
|
||||
// Stack with only one item (need 2 for KECCAK256)
|
||||
stack := []uint256.Int{
|
||||
*uint256.NewInt(0), // only offset, missing length
|
||||
}
|
||||
|
||||
mockScope := &mockOpContext{
|
||||
memory: memory,
|
||||
stack: stack,
|
||||
}
|
||||
|
||||
// This should not panic due to insufficient stack
|
||||
tracer.OnOpcode(0, byte(vm.KECCAK256), 0, 0, mockScope, nil, 0, nil)
|
||||
}
|
||||
|
||||
func TestKeccak256PreimageTracerLargeData(t *testing.T) {
|
||||
tracer, err := tracers.DefaultDirectory.New("keccak256PreimageTracer", &tracers.Context{}, nil, params.MainnetChainConfig)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Test with large data
|
||||
largeData := make([]byte, 1024)
|
||||
for i := range largeData {
|
||||
largeData[i] = byte(i % 256)
|
||||
}
|
||||
|
||||
memory := make([]byte, len(largeData))
|
||||
copy(memory, largeData)
|
||||
|
||||
stack := []uint256.Int{
|
||||
*uint256.NewInt(uint64(len(largeData))),
|
||||
*uint256.NewInt(0),
|
||||
}
|
||||
|
||||
mockScope := &mockOpContext{
|
||||
memory: memory,
|
||||
stack: stack,
|
||||
}
|
||||
|
||||
// Call OnOpcode with KECCAK256
|
||||
tracer.OnOpcode(0, byte(vm.KECCAK256), 0, 0, mockScope, nil, 0, nil)
|
||||
|
||||
// Get result
|
||||
result, err := tracer.GetResult()
|
||||
require.NoError(t, err)
|
||||
|
||||
var hashes map[common.Hash]hexutil.Bytes
|
||||
err = json.Unmarshal(result, &hashes)
|
||||
require.NoError(t, err)
|
||||
|
||||
expectedHash := crypto.Keccak256Hash(largeData)
|
||||
require.Len(t, hashes, 1)
|
||||
require.Contains(t, hashes, expectedHash)
|
||||
require.Equal(t, hexutil.Bytes(largeData), hashes[expectedHash])
|
||||
}
|
||||
Loading…
Reference in a new issue