diff --git a/blockdb/types/execdata_receiptmeta.go b/blockdb/types/execdata_receiptmeta.go new file mode 100644 index 000000000..7e2392a75 --- /dev/null +++ b/blockdb/types/execdata_receiptmeta.go @@ -0,0 +1,65 @@ +package types + +import ( + "fmt" +) + +// receiptMetaSize is the encoded size of ReceiptMetaData, which is fixed. A version 2 +// section is exactly this many bytes followed by an encoded FrameReceiptData. +var receiptMetaSize = (&ReceiptMetaData{}).SizeSSZ() + +// EncodeReceiptMetaSection encodes the receipt metadata section. +// +// Frame content is appended after the fixed metadata rather than stored in a section of +// its own: the per-transaction index entry has a fixed width, so a new section pointer +// would change the object format and make every object already written unreadable. The +// version field on the metadata says whether the tail is there. +func EncodeReceiptMetaSection(meta *ReceiptMetaData, frames *FrameReceiptData) ([]byte, error) { + if frames == nil { + meta.Version = ReceiptMetaVersion1 + } else { + meta.Version = ReceiptMetaVersion2 + } + + encoded, err := meta.MarshalSSZ() + if err != nil { + return nil, fmt.Errorf("marshal receipt metadata: %w", err) + } + + if frames == nil { + return encoded, nil + } + + frameData, err := frames.MarshalSSZ() + if err != nil { + return nil, fmt.Errorf("marshal frame receipt data: %w", err) + } + + return append(encoded, frameData...), nil +} + +// DecodeReceiptMetaSection decodes a receipt metadata section, returning the frame +// content when the section carries any. Sections written before frame transactions +// existed decode with a nil frame result. +func DecodeReceiptMetaSection(raw []byte) (*ReceiptMetaData, *FrameReceiptData, error) { + if len(raw) < receiptMetaSize { + return nil, nil, fmt.Errorf("receipt metadata truncated: need %d bytes, got %d", receiptMetaSize, len(raw)) + } + + meta := &ReceiptMetaData{} + if err := meta.UnmarshalSSZ(raw[:receiptMetaSize]); err != nil { + return nil, nil, fmt.Errorf("unmarshal receipt metadata: %w", err) + } + + tail := raw[receiptMetaSize:] + if meta.Version < ReceiptMetaVersion2 || len(tail) == 0 { + return meta, nil, nil + } + + frames := &FrameReceiptData{} + if err := frames.UnmarshalSSZ(tail); err != nil { + return nil, nil, fmt.Errorf("unmarshal frame receipt data: %w", err) + } + + return meta, frames, nil +} diff --git a/blockdb/types/execdata_receiptmeta_test.go b/blockdb/types/execdata_receiptmeta_test.go new file mode 100644 index 000000000..1610cc92c --- /dev/null +++ b/blockdb/types/execdata_receiptmeta_test.go @@ -0,0 +1,138 @@ +package types + +import ( + "testing" +) + +func sampleReceiptMeta() *ReceiptMetaData { + meta := &ReceiptMetaData{ + Version: ReceiptMetaVersion1, + Status: 1, + TxType: 6, + CumulativeGasUsed: 0x4e86, + GasUsed: 0x4e86, + } + meta.EffectiveGasPrice.SetUint64(0x773642f2) + copy(meta.From[:], []byte{0x7a, 0x11}) + copy(meta.To[:], []byte{0x30, 0x59}) + + return meta +} + +// A receipt with no frame content encodes exactly as it always did, so objects written +// before frame transactions existed and objects written after are the same shape. +func TestReceiptMetaSectionWithoutFramesIsUnchanged(t *testing.T) { + meta := sampleReceiptMeta() + + encoded, err := EncodeReceiptMetaSection(meta, nil) + if err != nil { + t.Fatalf("encode failed: %v", err) + } + + if len(encoded) != receiptMetaSize { + t.Fatalf("encoded %d bytes, want the fixed %d", len(encoded), receiptMetaSize) + } + + decoded, frames, err := DecodeReceiptMetaSection(encoded) + if err != nil { + t.Fatalf("decode failed: %v", err) + } + + if frames != nil { + t.Error("a receipt with no frame content must decode without any") + } + + if decoded.Version != ReceiptMetaVersion1 { + t.Errorf("version = %d, want %d", decoded.Version, ReceiptMetaVersion1) + } + + if decoded.GasUsed != meta.GasUsed || decoded.Status != meta.Status { + t.Error("receipt metadata did not survive the round trip") + } +} + +func TestReceiptMetaSectionRoundTripsFrames(t *testing.T) { + meta := sampleReceiptMeta() + + frames := &FrameReceiptData{ + Frames: []FrameReceiptEntry{ + {Status: 1, ExecGasUsed: 0x33, StateGasUsed: 0, LogCount: 0}, + {Status: 2, ExecGasUsed: 0, StateGasUsed: 0, LogCount: 0}, + {Status: 1, ExecGasUsed: 21000, StateGasUsed: 5, LogCount: 3}, + }, + } + copy(frames.Payer[:], []byte{0x6d, 0xf3, 0x54, 0x38}) + + encoded, err := EncodeReceiptMetaSection(meta, frames) + if err != nil { + t.Fatalf("encode failed: %v", err) + } + + if len(encoded) <= receiptMetaSize { + t.Fatalf("encoded %d bytes, want more than the fixed %d", len(encoded), receiptMetaSize) + } + + // The version has to say the tail is there, or a reader will not look for it. + if meta.Version != ReceiptMetaVersion2 { + t.Errorf("version = %d, want %d", meta.Version, ReceiptMetaVersion2) + } + + decodedMeta, decodedFrames, err := DecodeReceiptMetaSection(encoded) + if err != nil { + t.Fatalf("decode failed: %v", err) + } + + if decodedMeta.GasUsed != meta.GasUsed { + t.Error("receipt metadata did not survive alongside the frames") + } + + if decodedFrames == nil { + t.Fatal("frame content was lost") + } + + if decodedFrames.Payer != frames.Payer { + t.Errorf("payer = %x, want %x", decodedFrames.Payer, frames.Payer) + } + + if len(decodedFrames.Frames) != len(frames.Frames) { + t.Fatalf("frames = %d, want %d", len(decodedFrames.Frames), len(frames.Frames)) + } + + for i := range frames.Frames { + if decodedFrames.Frames[i] != frames.Frames[i] { + t.Errorf("frame %d = %+v, want %+v", i, decodedFrames.Frames[i], frames.Frames[i]) + } + } +} + +// A section written before frame transactions existed carries version 1 and no tail. It +// must keep decoding, which is the whole reason the frames go behind a version rather +// than into a new section of the object. +func TestReceiptMetaSectionDecodesLegacySections(t *testing.T) { + meta := sampleReceiptMeta() + meta.Version = ReceiptMetaVersion1 + + legacy, err := meta.MarshalSSZ() + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + + decoded, frames, err := DecodeReceiptMetaSection(legacy) + if err != nil { + t.Fatalf("decode of a legacy section failed: %v", err) + } + + if frames != nil { + t.Error("a legacy section has no frame content") + } + + if decoded.CumulativeGasUsed != meta.CumulativeGasUsed { + t.Error("legacy receipt metadata did not decode") + } +} + +func TestReceiptMetaSectionRejectsTruncatedInput(t *testing.T) { + if _, _, err := DecodeReceiptMetaSection(make([]byte, receiptMetaSize-1)); err == nil { + t.Error("a section shorter than the fixed metadata must not decode") + } +} diff --git a/blockdb/types/execdata_sections.go b/blockdb/types/execdata_sections.go index d78a484cf..ad66af6b6 100644 --- a/blockdb/types/execdata_sections.go +++ b/blockdb/types/execdata_sections.go @@ -12,6 +12,7 @@ const ( CallTypeCreate = 3 CallTypeCreate2 = 4 CallTypeSelfDestruct = 5 + CallTypeFrame = 6 ) // Call status constants for binary encoding. @@ -129,8 +130,49 @@ type BlockReceiptMeta struct { // Receipt metadata version. Bump when adding new fields. const ( ReceiptMetaVersion1 = 1 + + // ReceiptMetaVersion2 marks a section that carries frame content after the fixed + // metadata: the payer and per-frame results of an EIP-8141 frame transaction, which + // a receipt reports and no other section holds. + ReceiptMetaVersion2 = 2 ) +// FrameStatusUnknown marks a frame the client reported no result for, which is neither +// a success nor a failure. EIP-8141's own statuses start at zero, and zero is how a +// failure is spelled, so the absence of a result needs a value of its own. +const FrameStatusUnknown uint8 = 255 + +// FrameReceiptEntry is the result a receipt reports for one frame. +type FrameReceiptEntry struct { + // Status is EIP-8141's per-frame status: 0 failed, 1 success, 2 skipped. A skipped + // frame is neither - an earlier frame in its atomic batch failed and it never ran. + Status uint8 + + // EIP-8037 accounts for gas in two dimensions, and the receipt reports both per + // frame. State gas is a final attribution rather than a running total: a later frame + // can retroactively reduce an earlier one through a state-gas refill. + ExecGasUsed uint64 + StateGasUsed uint64 + + // LogCount is how many logs the frame emitted. The transaction's logs are the + // per-frame lists concatenated in frame order, so these counts partition the events + // section and attribute each log to the frame that emitted it. + LogCount uint32 +} + +// FrameReceiptData is the frame-transaction content of a receipt. +// +// A frame transaction's own fields - its targets, values, calldata and gas budgets - are +// recoverable from the transaction in the beacon block. What only the receipt holds is +// who paid and what each frame did, so that is what is kept here. +type FrameReceiptData struct { + // Payer settled the transaction's fee. For a sponsored transaction it is a paymaster + // rather than the sender, which is the point of the field. + Payer [20]byte + + Frames []FrameReceiptEntry `ssz-max:"64"` +} + // ReceiptMetaData holds per-transaction receipt metadata needed to // reconstruct a full eth_getTransactionReceipt JSON response. // Stored in the ReceiptMeta section (bitmap flag 0x08) of the execution diff --git a/blockdb/types/execdata_sections_ssz.go b/blockdb/types/execdata_sections_ssz.go index 35b94dd99..5eb0dc81a 100644 --- a/blockdb/types/execdata_sections_ssz.go +++ b/blockdb/types/execdata_sections_ssz.go @@ -1,5 +1,5 @@ // Code generated by dynamic-ssz. DO NOT EDIT. -// Hash: e3d1774fca5db9c355caeb23b8ffdaafe31f862babbbb051946a0d35ad37846d +// Hash: 5ac8fee5988b51b83905e5a2c2b7593601b6a1c86487c535267f221c91b035ad // Version: v1.4.0-pre.2 (https://github.com/pk910/dynamic-ssz) package types @@ -19,6 +19,7 @@ var _ = sszutils.Annotate[BlockReceiptMeta](`ssz-static:"true"`) var _ = sszutils.Annotate[StateChangeAccount](`ssz-static:"false"`) var _ = sszutils.Annotate[FlatCallFrame](`ssz-static:"false"`) var _ = sszutils.Annotate[EventData](`ssz-static:"false"`) +var _ = sszutils.Annotate[FrameReceiptData](`ssz-static:"false"`) // MarshalSSZ marshals the *ReceiptMetaData to SSZ-encoded bytes. func (t *ReceiptMetaData) MarshalSSZ() ([]byte, error) { @@ -1052,3 +1053,177 @@ func (t *EventData) HashTreeRootWith(hh sszutils.HashWalker) error { hh.Merkleize(idx) return nil } + +// MarshalSSZ marshals the *FrameReceiptData to SSZ-encoded bytes. +func (t *FrameReceiptData) MarshalSSZ() ([]byte, error) { + return dynssz.GetGlobalDynSsz().MarshalSSZ(t) +} + +// MarshalSSZTo marshals the *FrameReceiptData to SSZ-encoded bytes, appending to the provided buffer. +func (t *FrameReceiptData) MarshalSSZTo(buf []byte) (dst []byte, err error) { + dst = buf + if t == nil { + t = new(FrameReceiptData) + } + dstlen := len(dst) + { // Static Field #0 'Payer' + dst = append(dst, t.Payer[:20]...) + } + // Offset Field #1 'Frames' + dst = append(dst, 0, 0, 0, 0) + { // Dynamic Field #1 'Frames' + if off := uint64(len(dst)) - uint64(dstlen); off > math.MaxUint32 { + return nil, sszutils.ErrOffsetOverflowFn(off) + } else { + binary.LittleEndian.PutUint32(dst[dstlen+20:], uint32(off)) + } + t := t.Frames + vlen := len(t) + if vlen > 64 { + return nil, sszutils.ErrorWithPath(sszutils.ErrListLengthFn(vlen, 64), "Frames") + } + for idx1 := range vlen { + t := &t[idx1] + { // Static Field #0 'Status' + dst = append(dst, byte(t.Status)) + } + { // Static Field #1 'ExecGasUsed' + dst = binary.LittleEndian.AppendUint64(dst, t.ExecGasUsed) + } + { // Static Field #2 'StateGasUsed' + dst = binary.LittleEndian.AppendUint64(dst, t.StateGasUsed) + } + { // Static Field #3 'LogCount' + dst = binary.LittleEndian.AppendUint32(dst, t.LogCount) + } + } + } + return dst, nil +} + +// UnmarshalSSZ unmarshals the *FrameReceiptData from SSZ-encoded bytes. +func (t *FrameReceiptData) UnmarshalSSZ(buf []byte) (err error) { + buflen := uint64(len(buf)) + if buflen < 24 { + return sszutils.ErrFixedFieldsEOFFn(buflen, 24) + } + { // Field #0 'Payer' (static) + buf := buf[0:20] + copy(t.Payer[:], buf) + } + // Field #1 'Frames' (offset) + offset1 := binary.LittleEndian.Uint32(buf[20:24]) + if offset1 != 24 { + return sszutils.ErrorWithPath(sszutils.ErrFirstOffsetMismatchFn(offset1, 24), "Frames:o") + } + { // Field #1 'Frames' (dynamic) + buf := buf[offset1:] + val1 := t.Frames + itemCount := len(buf) / 21 + if len(buf)%21 != 0 { + return sszutils.ErrorWithPath(sszutils.ErrListNotAlignedFn(len(buf), 21), "Frames") + } + if itemCount > 64 { + return sszutils.ErrorWithPath(sszutils.ErrListLengthFn(itemCount, 64), "Frames") + } + val1 = sszutils.ExpandSlice(val1, itemCount) + for idx1 := range itemCount { + val2 := val1[idx1] + buf := buf[21*idx1 : 21*(idx1+1)] + buflen := uint64(len(buf)) + if 21 != buflen { + if 21 > buflen { + return sszutils.ErrorWithPathf(sszutils.ErrFixedFieldsEOFFn(buflen, 21), "Frames[%d]", idx1) + } + return sszutils.ErrorWithPathf(sszutils.ErrTrailingDataFn(buflen-21), "Frames[%d]", idx1) + } + { // Field #0 'Status' (static) + buf := buf[0:1] + val2.Status = buf[0] + } + { // Field #1 'ExecGasUsed' (static) + buf := buf[1:9] + val2.ExecGasUsed = binary.LittleEndian.Uint64(buf) + } + { // Field #2 'StateGasUsed' (static) + buf := buf[9:17] + val2.StateGasUsed = binary.LittleEndian.Uint64(buf) + } + { // Field #3 'LogCount' (static) + buf := buf[17:21] + val2.LogCount = binary.LittleEndian.Uint32(buf) + } + val1[idx1] = val2 + } + t.Frames = val1 + } + return nil +} + +// SizeSSZ returns the SSZ encoded size of the *FrameReceiptData. +func (t *FrameReceiptData) SizeSSZ() (size int) { + if t == nil { + t = new(FrameReceiptData) + } + // Field #0 'Payer' static (20 bytes) + // Field #1 'Frames' offset (4 bytes) + size += 24 + { // Dynamic field #1 'Frames' + size += len(t.Frames) * 21 + } + return size +} + +// HashTreeRoot computes the SSZ hash tree root of the *FrameReceiptData. +func (t *FrameReceiptData) HashTreeRoot() (root [32]byte, err error) { + err = hasher.WithDefaultHasher(func(hh sszutils.HashWalker) (err error) { + err = t.HashTreeRootWith(hh) + if err == nil { + root, err = hh.HashRoot() + } + return + }) + return +} + +// HashTreeRootWith computes the SSZ hash tree root of the *FrameReceiptData using the given hash walker. +func (t *FrameReceiptData) HashTreeRootWith(hh sszutils.HashWalker) error { + if t == nil { + t = new(FrameReceiptData) + } + idx := hh.StartTree(sszutils.TreeTypeNone) + { // Field #0 'Payer' + hh.PutBytes(t.Payer[:20]) + } + { // Field #1 'Frames' + t := t.Frames + vlen := uint64(len(t)) + if vlen > 64 { + return sszutils.ErrorWithPath(sszutils.ErrListLengthFn(vlen, 64), "Frames") + } + idx := hh.StartTree(sszutils.TreeTypeBinary) + for idx1 := range len(t) { + t := &t[idx1] + idx := hh.StartTree(sszutils.TreeTypeNone) + { // Field #0 'Status' + hh.PutUint8(t.Status) + } + { // Field #1 'ExecGasUsed' + hh.PutUint64(t.ExecGasUsed) + } + { // Field #2 'StateGasUsed' + hh.PutUint64(t.StateGasUsed) + } + { // Field #3 'LogCount' + hh.PutUint32(t.LogCount) + } + hh.Merkleize(idx) + if (idx1+1)%256 == 0 { + hh.Collapse() + } + } + hh.MerkleizeWithMixin(idx, vlen, sszutils.CalculateLimit(64, vlen, 32)) + } + hh.Merkleize(idx) + return nil +} diff --git a/blockdb/types/generate.go b/blockdb/types/generate.go index fc4701b53..b94f6deb5 100644 --- a/blockdb/types/generate.go +++ b/blockdb/types/generate.go @@ -1,3 +1,3 @@ package types -//go:generate go tool dynssz-gen -without-dynamic-expressions -package . -legacy -output execdata_sections_ssz.go -types ReceiptMetaData,BlockReceiptMeta,StateChangeAccount,FlatCallFrame,EventData +//go:generate go tool dynssz-gen -without-dynamic-expressions -package . -legacy -output execdata_sections_ssz.go -types ReceiptMetaData,BlockReceiptMeta,StateChangeAccount,FlatCallFrame,EventData,FrameReceiptData diff --git a/clients/execution/rpc/calltrace_stream.go b/clients/execution/rpc/calltrace_stream.go index d544da7ee..c3ba4c6a6 100644 --- a/clients/execution/rpc/calltrace_stream.go +++ b/clients/execution/rpc/calltrace_stream.go @@ -62,12 +62,12 @@ func decodeCallTraceResult(dec *json.Decoder, result *CallTraceResult, payloadLi return decodeScalar(dec, key, &result.TxHash) case "result": - call, err := decodeCallFrame(dec, payloadLimit) + roots, err := decodeCallFrameRoots(dec, payloadLimit) if err != nil { return err } - result.Result = call + result.Roots = roots return nil @@ -85,7 +85,79 @@ func decodeCallTraceResult(dec *json.Decoder, result *CallTraceResult, payloadLi func decodeCallFrame(dec *json.Decoder, payloadLimit int) (*CallTraceCall, error) { call := &CallTraceCall{} - present, err := decodeJSONObject(dec, "call frame", func(key string) error { + present, err := decodeJSONObject(dec, "call frame", callFrameField(dec, call, payloadLimit)) + if err != nil { + return nil, err + } + + if !present { + return nil, nil + } + + return call, nil +} + +// decodeCallFrameBody decodes a call frame whose opening brace has already been read. +func decodeCallFrameBody(dec *json.Decoder, payloadLimit int) (*CallTraceCall, error) { + call := &CallTraceCall{} + + if err := decodeJSONObjectBody(dec, "call frame", callFrameField(dec, call, payloadLimit)); err != nil { + return nil, err + } + + return call, nil +} + +// decodeCallFrameRoots decodes a transaction's trace result: either one root call frame, +// or a list of them for a transaction whose client decomposes it into several. +func decodeCallFrameRoots(dec *json.Decoder, payloadLimit int) ([]*CallTraceCall, error) { + tok, err := dec.Token() + if err != nil { + return nil, asDecodeError(fmt.Errorf("read trace result start: %w", err)) + } + + switch tok { + case nil: + return nil, nil + + case json.Delim('{'): + call, err := decodeCallFrameBody(dec, payloadLimit) + if err != nil { + return nil, err + } + + return []*CallTraceCall{call}, nil + + case json.Delim('['): + roots := make([]*CallTraceCall, 0, 4) + + for dec.More() { + call, err := decodeCallFrame(dec, payloadLimit) + if err != nil { + return nil, err + } + + if call != nil { + roots = append(roots, call) + } + } + + if _, err := dec.Token(); err != nil { + return nil, asDecodeError(fmt.Errorf("read trace result end: %w", err)) + } + + return roots, nil + + default: + return nil, &ResponseDecodeError{ + Err: fmt.Errorf("expected '{' or '[' for trace result, got %v", tok), + } + } +} + +// callFrameField returns the field decoder for one call frame's members. +func callFrameField(dec *json.Decoder, call *CallTraceCall, payloadLimit int) func(key string) error { + return func(key string) error { switch key { case "type": return decodeScalar(dec, key, &call.Type) @@ -99,6 +171,12 @@ func decodeCallFrame(dec *json.Decoder, payloadLimit int) (*CallTraceCall, error return decodeScalar(dec, key, &call.Gas) case "gasUsed": return decodeScalar(dec, key, &call.GasUsed) + case "regularGasUsed": + return decodeScalar(dec, key, &call.RegularGasUsed) + case "stateGasUsed": + return decodeScalar(dec, key, &call.StateGasUsed) + case "gasRefund": + return decodeScalar(dec, key, &call.GasRefund) case "error": return decodeScalar(dec, key, &call.Error) @@ -122,16 +200,7 @@ func decodeCallFrame(dec *json.Decoder, payloadLimit int) (*CallTraceCall, error default: return skipJSONValue(dec) } - }) - if err != nil { - return nil, err - } - - if !present { - return nil, nil } - - return call, nil } // decodeCallFrames decodes the nested "calls" array of a call frame. @@ -186,29 +255,39 @@ func decodeJSONObject(dec *json.Decoder, what string, decodeField func(key strin return false, &ResponseDecodeError{Err: fmt.Errorf("expected '{' for %s, got %v", what, tok)} } + if err := decodeJSONObjectBody(dec, what, decodeField); err != nil { + return false, err + } + + return true, nil +} + +// decodeJSONObjectBody reads the members of an object whose opening brace has already +// been read, invoking decodeField for every key. +func decodeJSONObjectBody(dec *json.Decoder, what string, decodeField func(key string) error) error { for dec.More() { keyTok, err := dec.Token() if err != nil { - return false, asDecodeError(fmt.Errorf("read %s key: %w", what, err)) + return asDecodeError(fmt.Errorf("read %s key: %w", what, err)) } key, ok := keyTok.(string) if !ok { - return false, &ResponseDecodeError{ + return &ResponseDecodeError{ Err: fmt.Errorf("expected string key in %s, got %T", what, keyTok), } } if err := decodeField(key); err != nil { - return false, err + return err } } if _, err := dec.Token(); err != nil { - return false, asDecodeError(fmt.Errorf("read %s end: %w", what, err)) + return asDecodeError(fmt.Errorf("read %s end: %w", what, err)) } - return true, nil + return nil } // decodeScalar decodes the value of a single object field. diff --git a/clients/execution/rpc/calltrace_stream_test.go b/clients/execution/rpc/calltrace_stream_test.go index f4ddd9be3..76559350c 100644 --- a/clients/execution/rpc/calltrace_stream_test.go +++ b/clients/execution/rpc/calltrace_stream_test.go @@ -11,6 +11,15 @@ import ( "github.com/stretchr/testify/require" ) +// firstRoot returns a transaction's single top-level call frame, or nil when it has none. +func firstRoot(result CallTraceResult) *CallTraceCall { + if len(result.Roots) == 0 { + return nil + } + + return result.Roots[0] +} + // decodeTraceArray runs the streaming call trace decoder over a raw result array. func decodeTraceArray(t *testing.T, body string, payloadLimit int) ([]CallTraceResult, error) { t.Helper() @@ -69,7 +78,7 @@ func TestDecodeCallTraceResults(t *testing.T) { require.NoError(t, err) require.Len(t, results, 2) - root := results[0].Result + root := firstRoot(results[0]) require.NotNil(t, root) assert.Equal(t, "CALL", root.Type) assert.Equal(t, "0x722d316672c8be206c3d228d087d1dc948a61345", strings.ToLower(root.To.Hex())) @@ -88,7 +97,7 @@ func TestDecodeCallTraceResults(t *testing.T) { assert.Empty(t, root.Calls[1].Input) assert.Nil(t, root.Calls[1].Calls) - assert.Nil(t, results[1].Result) + assert.Nil(t, firstRoot(results[1])) } func TestDecodeCallTraceResultsPrunesPayloads(t *testing.T) { @@ -108,7 +117,7 @@ func TestDecodeCallTraceResultsPrunesPayloads(t *testing.T) { require.NoError(t, err) require.Len(t, results, 1) - root := results[0].Result + root := firstRoot(results[0]) require.NotNil(t, root) // One byte past the limit is what marks the payload as truncated. @@ -128,7 +137,7 @@ func TestDecodeCallTraceResultsKeepsPayloadsWithinLimit(t *testing.T) { require.Len(t, results, 1) // Exactly at the limit, so nothing is truncated and no marker byte is added. - assert.Equal(t, []byte{1, 2, 3, 4, 5}, []byte(results[0].Result.Input)) + assert.Equal(t, []byte{1, 2, 3, 4, 5}, []byte(firstRoot(results[0]).Input)) } func TestDecodeCallTraceResultsNullAndEmpty(t *testing.T) { @@ -169,7 +178,7 @@ func TestDecodeCallTraceResultsLenientRevertReason(t *testing.T) { results, err := decodeTraceArray(t, body, 1024) require.NoError(t, err) require.Len(t, results, 1) - assert.Equal(t, test.expected, []byte(results[0].Result.RevertReason)) + assert.Equal(t, test.expected, []byte(firstRoot(results[0]).RevertReason)) }) } } @@ -231,3 +240,100 @@ func TestSkipJSONValue(t *testing.T) { require.NoError(t, err) assert.Equal(t, "c", key) } + +// A client that decomposes an EIP-8141 frame transaction has one top-level call per +// frame, and reports them as a list rather than the single object an ordinary +// transaction produces. No client does this yet, so the shape is accepted in advance. +func TestDecodeCallTraceResultsAcceptsMultipleRoots(t *testing.T) { + const body = `[ + { + "txHash": "0x1111111111111111111111111111111111111111111111111111111111111111", + "result": [ + { + "type": "CALL", + "from": "0x6df35438a4dfcdbd25c7a364ab77e3cfdce87fc5", + "to": "0x0000000000000000000000000000000000008141", + "gas": "0x1388", + "gasUsed": "0x33", + "input": "0x" + }, + { + "type": "CALL", + "from": "0x6df35438a4dfcdbd25c7a364ab77e3cfdce87fc5", + "to": "0x30592ef78d262bc79f0fe46355e07a51d685e382", + "gas": "0x7530", + "gasUsed": "0x5208", + "input": "0xdeadbeef", + "calls": [ + { + "type": "STATICCALL", + "from": "0x30592ef78d262bc79f0fe46355e07a51d685e382", + "to": "0x0000000000000000000000000000000000000004", + "gas": "0x100", + "gasUsed": "0x12", + "input": "0x" + } + ] + } + ] + } + ]` + + results, err := decodeTraceArray(t, body, 1024) + require.NoError(t, err) + require.Len(t, results, 1) + require.Len(t, results[0].Roots, 2) + + assert.Equal(t, "0x0000000000000000000000000000000000008141", strings.ToLower(results[0].Roots[0].To.Hex())) + assert.Equal(t, uint64(0x33), uint64(results[0].Roots[0].GasUsed)) + + assert.Equal(t, "0x30592ef78d262bc79f0fe46355e07a51d685e382", strings.ToLower(results[0].Roots[1].To.Hex())) + require.Len(t, results[0].Roots[1].Calls, 1) + assert.Equal(t, "STATICCALL", results[0].Roots[1].Calls[0].Type) +} + +// Verbatim callTracer output from ethrex (eip8141-v2-lenient) for a four-frame +// transaction: one self-addressed childless placeholder that says nothing about the +// frames, with the EIP-8037 gas dimensions reported and gasUsed left at zero. +func TestDecodeCallTraceResultsReadsEip8037GasDimensions(t *testing.T) { + const body = `[ + { + "result": { + "from": "0x6bcb3483cd582d6011e80805e0c6a90d42b98710", + "gas": "0x1b888", + "gasRefund": "0x1664c", + "gasUsed": "0x0", + "input": "0x", + "regularGasUsed": "0x523c", + "stateGasUsed": "0x0", + "to": "0x6bcb3483cd582d6011e80805e0c6a90d42b98710", + "type": "CALL", + "value": "0x0" + }, + "txHash": "0x01a68783c4c3fa7af37526d9c34b25e66dea81c44840c28c905faf532c99eff1" + } + ]` + + results, err := decodeTraceArray(t, body, 1024) + require.NoError(t, err) + require.Len(t, results, 1) + + root := firstRoot(results[0]) + require.NotNil(t, root) + assert.Empty(t, root.Calls, "the placeholder carries no frames") + assert.Equal(t, root.From, root.To, "the placeholder addresses the sender itself") + + assert.Equal(t, uint64(0x523c), uint64(root.RegularGasUsed)) + assert.Equal(t, uint64(0x1664c), uint64(root.GasRefund)) + + // gasUsed is zero here, so the cost has to come from the two dimensions. + assert.Zero(t, uint64(root.GasUsed)) + assert.Equal(t, uint64(0x523c), root.TotalGasUsed()) +} + +// Where a client fills gasUsed in, it is authoritative and the dimensions are not summed +// on top of it. +func TestTotalGasUsedPrefersReportedGasUsed(t *testing.T) { + call := &CallTraceCall{GasUsed: 0x64e86, RegularGasUsed: 0xb426, StateGasUsed: 0x59a60} + assert.Equal(t, uint64(0x64e86), call.TotalGasUsed()) +} diff --git a/clients/execution/rpc/executionapi.go b/clients/execution/rpc/executionapi.go index 490fed640..c63bd8b04 100644 --- a/clients/execution/rpc/executionapi.go +++ b/clients/execution/rpc/executionapi.go @@ -2,6 +2,7 @@ package rpc import ( "context" + "encoding/json" "fmt" "math/big" "net/url" @@ -13,6 +14,7 @@ import ( "github.com/ethereum/go-ethereum/ethclient" "github.com/ethereum/go-ethereum/p2p" "github.com/ethereum/go-ethereum/rpc" + "github.com/ethpandaops/spamoor/txtypes" "github.com/sirupsen/logrus" "golang.org/x/crypto/ssh" @@ -259,8 +261,60 @@ func (ec *ExecutionClient) GetBalanceAt(ctx context.Context, wallet common.Addre return ec.ethClient.BalanceAt(ctx, wallet, blockNumber) } -func (ec *ExecutionClient) GetTransactionReceipt(ctx context.Context, txHash common.Hash) (*types.Receipt, error) { - return ec.ethClient.TransactionReceipt(ctx, txHash) +// GetTransactionByHash looks up a single transaction and reports whether it is still +// pending. +// +// The response is decoded from raw JSON rather than through the typed ethclient: a +// transaction of a type go-ethereum cannot represent - an EIP-8141 frame transaction, +// or whatever the next fork adds - still yields the fields the node reported instead of +// failing the lookup outright. +func (ec *ExecutionClient) GetTransactionByHash(ctx context.Context, txHash common.Hash) (*txtypes.Transaction, bool, error) { + var raw json.RawMessage + if err := ec.rpcClient.CallContext(ctx, &raw, "eth_getTransactionByHash", txHash); err != nil { + return nil, false, fmt.Errorf("eth_getTransactionByHash failed: %w", err) + } + + if len(raw) == 0 || string(raw) == "null" { + return nil, false, fmt.Errorf("transaction %s not found", txHash.Hex()) + } + + tx, err := txtypes.UnmarshalJSONTx(raw) + if err != nil { + return nil, false, fmt.Errorf("unmarshal transaction %s: %w", txHash.Hex(), err) + } + + // A transaction that names no block has not been included yet. + var inclusion struct { + BlockHash *common.Hash `json:"blockHash"` + } + if err := json.Unmarshal(raw, &inclusion); err != nil { + return nil, false, fmt.Errorf("unmarshal transaction %s inclusion: %w", txHash.Hex(), err) + } + + return tx, inclusion.BlockHash == nil, nil +} + +// GetTransactionReceipt looks up a single transaction receipt. +// +// Like GetTransactionByHash it decodes raw JSON, which additionally preserves +// type-specific receipt content such as an EIP-8141 transaction's per-frame results and +// the payer that settled it. +func (ec *ExecutionClient) GetTransactionReceipt(ctx context.Context, txHash common.Hash) (*txtypes.Receipt, error) { + var raw json.RawMessage + if err := ec.rpcClient.CallContext(ctx, &raw, "eth_getTransactionReceipt", txHash); err != nil { + return nil, fmt.Errorf("eth_getTransactionReceipt failed: %w", err) + } + + if len(raw) == 0 || string(raw) == "null" { + return nil, fmt.Errorf("receipt for transaction %s not found", txHash.Hex()) + } + + receipt := &txtypes.Receipt{} + if err := json.Unmarshal(raw, receipt); err != nil { + return nil, fmt.Errorf("unmarshal receipt for transaction %s: %w", txHash.Hex(), err) + } + + return receipt, nil } func (ec *ExecutionClient) SendTransaction(ctx context.Context, tx *types.Transaction) error { diff --git a/clients/execution/rpc/traces.go b/clients/execution/rpc/traces.go index 2ba69025c..3bc0ed8a2 100644 --- a/clients/execution/rpc/traces.go +++ b/clients/execution/rpc/traces.go @@ -63,8 +63,15 @@ type CallTracerConfig struct { // CallTraceResult is the JSON result for one transaction from // debug_traceBlockByHash with the callTracer. type CallTraceResult struct { - TxHash common.Hash `json:"txHash"` - Result *CallTraceCall `json:"result"` + TxHash common.Hash + + // Roots are the transaction's top-level call frames. + // + // An ordinary transaction is a single call and has exactly one. An EIP-8141 frame + // transaction is a list of calls, so a client that decomposes one reports a root per + // frame; the callTracer is not part of execution-apis and nothing specifies how, so + // both a lone object and a list are accepted here. + Roots []*CallTraceCall } // CallTraceCall is a single call frame in the callTracer output. @@ -85,6 +92,25 @@ type CallTraceCall struct { // Revert reason (some clients include this separately) RevertReason LenientHexBytes `json:"revertReason,omitempty"` + + // EIP-8037 splits gas into a regular and a state dimension. Clients on such a chain + // report both alongside gasUsed, which they do not always fill in. + RegularGasUsed hexutil.Uint64 `json:"regularGasUsed"` + StateGasUsed hexutil.Uint64 `json:"stateGasUsed"` + GasRefund hexutil.Uint64 `json:"gasRefund"` +} + +// TotalGasUsed returns the gas the call consumed across both EIP-8037 dimensions. +// +// gasUsed is normally their sum, but not always: ethrex leaves it at zero on the root +// frame of a frame transaction while still reporting the dimensions, so falling back to +// their sum keeps the call's cost visible rather than recording it as free. +func (c *CallTraceCall) TotalGasUsed() uint64 { + if c.GasUsed != 0 { + return uint64(c.GasUsed) + } + + return uint64(c.RegularGasUsed) + uint64(c.StateGasUsed) } // CallTypeFromString converts a callTracer type string to a numeric call type. diff --git a/dbtypes/dbtypes.go b/dbtypes/dbtypes.go index e1d4c24f7..987f126a9 100644 --- a/dbtypes/dbtypes.go +++ b/dbtypes/dbtypes.go @@ -651,8 +651,21 @@ type ElTxHash struct { const ( ElTxTypeMask uint8 = 0x7F // bits 0-6: EVM tx type ElTxFlagCreate uint8 = 0x80 // bit 7: contract-creation tx (raw recipient was null) + + // ElTxTypeFrame is the EIP-8141 frame transaction type. + ElTxTypeFrame uint8 = 0x06 ) +// IsMultiTarget reports whether a transaction addresses more than one recipient. +// +// Such a transaction has no recipient of its own, so its el_transactions row carries +// to_id 0 - the id no account has - and its targets are read from the transaction +// itself. Callers must not read that as a contract creation, which is the other reason +// a row has no recipient. +func IsMultiTarget(txType uint8) bool { + return txType&ElTxTypeMask == ElTxTypeFrame +} + // ElTransactionInternal is a per-account aggregate of internal calls within a // transaction. One row per (tx_uid, account_id) regardless of how many sub- // calls touched the account — keeps insert volume bounded for call-heavy txs. diff --git a/go.mod b/go.mod index c26563456..2cd8f6055 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/ethpandaops/ethcore v0.0.0-20260807103219-0fb0622e156b github.com/ethpandaops/ethwallclock v0.4.0 github.com/ethpandaops/go-eth2-client v0.1.7 + github.com/ethpandaops/spamoor v1.2.4-0.20260828234109-9ac9b69d9050 github.com/ethpandaops/xatu v1.22.1-0.20260824050538-619c572d19c3 github.com/ethpandaops/xatu-cbt v0.0.0-20260825024339-8eadec716ac8 github.com/go-redis/redis/v8 v8.11.5 @@ -77,10 +78,10 @@ require ( github.com/getsentry/sentry-go v0.35.3 // indirect github.com/go-faster/city v1.0.1 // indirect github.com/go-faster/errors v0.7.1 // indirect - github.com/go-openapi/jsonpointer v0.19.6 // indirect - github.com/go-openapi/jsonreference v0.20.2 // indirect - github.com/go-openapi/spec v0.20.6 // indirect - github.com/go-openapi/swag v0.22.3 // indirect + github.com/go-openapi/jsonpointer v0.21.1 // indirect + github.com/go-openapi/jsonreference v0.21.0 // indirect + github.com/go-openapi/spec v0.21.0 // indirect + github.com/go-openapi/swag v0.23.1 // indirect github.com/gofrs/flock v0.13.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/holiman/bloomfilter/v2 v2.0.3 // indirect @@ -105,7 +106,7 @@ require ( github.com/libp2p/go-netroute v0.4.0 // indirect github.com/libp2p/go-reuseport v0.4.0 // indirect github.com/libp2p/go-yamux/v5 v5.1.0 // indirect - github.com/mailru/easyjson v0.7.7 // indirect + github.com/mailru/easyjson v0.9.0 // indirect github.com/marten-seemann/tcp v0.0.0-20210406111302-dfbc87cc63fd // indirect github.com/miekg/dns v1.1.68 // indirect github.com/mikioh/tcpinfo v0.0.0-20190314235526-30a79bb1804b // indirect @@ -176,7 +177,7 @@ require ( go.uber.org/zap v1.28.0 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect golang.org/x/mod v0.38.0 // indirect - golang.org/x/telemetry v0.0.0-20260708182218-49f421fb7959 // indirect + golang.org/x/telemetry v0.0.0-20260804195142-bdd03c3c8848 // indirect golang.org/x/tools v0.48.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260720211330-0afa2a65878a // indirect @@ -191,9 +192,9 @@ require ( github.com/Masterminds/semver/v3 v3.5.0 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/beorn7/perks v1.0.1 // indirect - github.com/bits-and-blooms/bitset v1.24.4 // indirect + github.com/bits-and-blooms/bitset v1.24.6 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/consensys/gnark-crypto v0.18.1 // indirect + github.com/consensys/gnark-crypto v0.21.0 // indirect github.com/deckarep/golang-set/v2 v2.8.0 // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect @@ -240,7 +241,6 @@ require ( github.com/spf13/cast v1.10.0 // indirect github.com/supranational/blst v0.3.16 // indirect github.com/tdewolff/parse v2.3.4+incompatible // indirect - github.com/tdewolff/test v1.0.9 // indirect github.com/tklauser/go-sysconf v0.4.0 // indirect github.com/tklauser/numcpus v0.12.0 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect diff --git a/go.sum b/go.sum index 61487a7d7..d2cce69be 100644 --- a/go.sum +++ b/go.sum @@ -41,8 +41,8 @@ github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz github.com/benbjohnson/clock v1.3.5/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= -github.com/bits-and-blooms/bitset v1.24.4 h1:95H15Og1clikBrKr/DuzMXkQzECs1M6hhoGXLwLQOZE= -github.com/bits-and-blooms/bitset v1.24.4/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= +github.com/bits-and-blooms/bitset v1.24.6 h1:qcrftZUVBIwfs+m+nhoCBAPT+ZPZZjti8SbHbDQQkZ4= +github.com/bits-and-blooms/bitset v1.24.6/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/chuckpreslar/emission v0.0.0-20170206194824-a7ddd980baf9 h1:xz6Nv3zcwO2Lila35hcb0QloCQsc38Al13RNEzWRpX4= @@ -72,8 +72,8 @@ github.com/cockroachdb/swiss v0.0.0-20251224182025-b0f6560f979b h1:VXvSNzmr8hMj8 github.com/cockroachdb/swiss v0.0.0-20251224182025-b0f6560f979b/go.mod h1:yBRu/cnL4ks9bgy4vAASdjIW+/xMlFwuHKqtmh3GZQg= github.com/cockroachdb/tokenbucket v0.0.0-20250429170803-42689b6311bb h1:3bCgBvB8PbJVMX1ouCcSIxvsqKPYM7gs72o0zC76n9g= github.com/cockroachdb/tokenbucket v0.0.0-20250429170803-42689b6311bb/go.mod h1:7nc4anLGjupUW/PeY5qiNYsdNXj7zopG+eqsS7To5IQ= -github.com/consensys/gnark-crypto v0.18.1 h1:RyLV6UhPRoYYzaFnPQA4qK3DyuDgkTgskDdoGqFt3fI= -github.com/consensys/gnark-crypto v0.18.1/go.mod h1:L3mXGFTe1ZN+RSJ+CLjUt9x7PNdx8ubaYfDROyp2Z8c= +github.com/consensys/gnark-crypto v0.21.0 h1:FDHibVIk4T5LkOKAkiN38g8gEvOxNcM10mLHOqvFTD0= +github.com/consensys/gnark-crypto v0.21.0/go.mod h1:hdTjDNjdkYJ1oVuc8emh9XEhfM1SbyZhJigFqItiOLk= github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= @@ -123,6 +123,8 @@ github.com/ethpandaops/ethwallclock v0.4.0 h1:+sgnhf4pk6hLPukP076VxkiLloE4L0Yk1y github.com/ethpandaops/ethwallclock v0.4.0/go.mod h1:y0Cu+mhGLlem19vnAV2x0hpFS5KZ7oOi2SWYayv9l24= github.com/ethpandaops/go-eth2-client v0.1.7 h1:EzZP50rrpVsjk2U8cOpN+kgNDTd/9IZNQXkh9t8G9AQ= github.com/ethpandaops/go-eth2-client v0.1.7/go.mod h1:MXQukU/345puJmB2EikoaeQIFizk3zbekPxwYyxG/t8= +github.com/ethpandaops/spamoor v1.2.4-0.20260828234109-9ac9b69d9050 h1:h+0THK4avP0BBQtRuR0aPy5LT8HTsDShumJmn9BehLE= +github.com/ethpandaops/spamoor v1.2.4-0.20260828234109-9ac9b69d9050/go.mod h1:9P2OdmRA+4kS/g5SO8KiH67KZhl9hHI/WAiLRhM28EQ= github.com/ethpandaops/xatu v1.22.1-0.20260824050538-619c572d19c3 h1:83LDwFEAijcssPdQ8ojzzbi+2mD3a+zL1b4V4cY1sMo= github.com/ethpandaops/xatu v1.22.1-0.20260824050538-619c572d19c3/go.mod h1:sDLPtT/bQgK+vExtPq77y2W4yHq9tRdiDu852yptTcU= github.com/ethpandaops/xatu-cbt v0.0.0-20260825024339-8eadec716ac8 h1:YfAXAt5fQcEnzPccCfByhXY2Vy9FlGrOp+NxGLx3QNY= @@ -160,19 +162,14 @@ github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= -github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= -github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= -github.com/go-openapi/jsonpointer v0.19.6 h1:eCs3fxoIi3Wh6vtgmLTOjdhSpiqphQ+DaPn38N2ZdrE= -github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= -github.com/go-openapi/jsonreference v0.20.0/go.mod h1:Ag74Ico3lPc+zR+qjn4XBUmXymS4zJbYVCZmcgkasdo= -github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= -github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= -github.com/go-openapi/spec v0.20.6 h1:ich1RQ3WDbfoeTqTAb+5EIxNmpKVJZWBNah9RAT0jIQ= -github.com/go-openapi/spec v0.20.6/go.mod h1:2OpW+JddWPrpXSCIX8eOx7lZ5iyuWj3RYR6VaaBKcWA= -github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= -github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= -github.com/go-openapi/swag v0.22.3 h1:yMBqmnQ0gyZvEb/+KzuWZOXgllrXT4SADYbvDaXHv/g= -github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= +github.com/go-openapi/jsonpointer v0.21.1 h1:whnzv/pNXtK2FbX/W9yJfRmE2gsmkfahjMKB0fZvcic= +github.com/go-openapi/jsonpointer v0.21.1/go.mod h1:50I1STOfbY1ycR8jGz8DaMeLCdXiI6aDteEdRNNzpdk= +github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= +github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4= +github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9ZY= +github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk= +github.com/go-openapi/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZU= +github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0= github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI= github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= @@ -377,11 +374,8 @@ github.com/libp2p/go-reuseport v0.4.0 h1:nR5KU7hD0WxXCJbmw7r2rhRYruNRl2koHw8fQsc github.com/libp2p/go-reuseport v0.4.0/go.mod h1:ZtI03j/wO5hZVDFo2jKywN6bYKWLOy8Se6DrI2E1cLU= github.com/libp2p/go-yamux/v5 v5.1.0 h1:8Qlxj4E9JGJAQVW6+uj2o7mqkqsIVlSUGmTWhlXzoHE= github.com/libp2p/go-yamux/v5 v5.1.0/go.mod h1:tgIQ07ObtRR/I0IWsFOyQIL9/dR5UXgc2s8xKmNZv1o= -github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= -github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= -github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= -github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= -github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= +github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= github.com/marcopolo/simnet v0.0.7 h1:DpH8BMGsF9+1w13L8rvCaAhb6nYJdY+dIXncDrssvUs= github.com/marcopolo/simnet v0.0.7/go.mod h1:tfQF1u2DmaB6WHODMtQaLtClEf3a296CKQLq5gAsIS0= github.com/marten-seemann/tcp v0.0.0-20210406111302-dfbc87cc63fd h1:br0buuQ854V8u83wA0rVZ8ttrq5CpaPZdvrK0LP2lOk= @@ -463,7 +457,6 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= -github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= @@ -631,7 +624,6 @@ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXf github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= -github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= @@ -656,8 +648,8 @@ github.com/tdewolff/minify v2.3.6+incompatible h1:2hw5/9ZvxhWLvBUnHE06gElGYz+Jv9 github.com/tdewolff/minify v2.3.6+incompatible/go.mod h1:9Ov578KJUmAWpS6NeZwRZyT56Uf6o3Mcz9CEsg8USYs= github.com/tdewolff/parse v2.3.4+incompatible h1:x05/cnGwIMf4ceLuDMBOdQ1qGniMoxpP46ghf0Qzh38= github.com/tdewolff/parse v2.3.4+incompatible/go.mod h1:8oBwCsVmUkgHO8M5iCzSIDtpzXOT0WXX9cWhz+bIzJQ= -github.com/tdewolff/test v1.0.9 h1:SswqJCmeN4B+9gEAi/5uqT0qpi1y2/2O47V/1hhGZT0= -github.com/tdewolff/test v1.0.9/go.mod h1:6DAvZliBAAnD7rhVgwaM7DE5/d9NMOAJ09SqYqeK4QE= +github.com/tdewolff/test v1.0.11 h1:FdLbwQVHxqG16SlkGveC0JVyrJN62COWTRyUFzfbtBE= +github.com/tdewolff/test v1.0.11/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8= github.com/thomaso-mirodin/intmath v0.0.0-20160323211736-5dc6d854e46e h1:cR8/SYRgyQCt5cNCMniB/ZScMkhI9nk8U5C7SbISXjo= github.com/thomaso-mirodin/intmath v0.0.0-20160323211736-5dc6d854e46e/go.mod h1:Tu4lItkATkonrYuvtVjG0/rhy15qrNGNTjPdaphtZ/8= github.com/timandy/routine v1.1.6 h1:cueNRVPutK8O6387LL7dmYPLNyS6aKlPCPi5qWCLdc8= @@ -833,8 +825,8 @@ golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/telemetry v0.0.0-20260708182218-49f421fb7959 h1:RJhm5l6Fo4rmEIcndxDllNhhf/fAx8qIm4t6A7vpm2A= -golang.org/x/telemetry v0.0.0-20260708182218-49f421fb7959/go.mod h1:LV7u5Oco+Z/g6XI7PqN+EUUUGGkEcmB1uj2ceI0fOVg= +golang.org/x/telemetry v0.0.0-20260804195142-bdd03c3c8848 h1:gw6MYMPjxGBM77qPFYzz0c2naANqd2FF66wZRteou4s= +golang.org/x/telemetry v0.0.0-20260804195142-bdd03c3c8848/go.mod h1:LV7u5Oco+Z/g6XI7PqN+EUUUGGkEcmB1uj2ceI0fOVg= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -905,7 +897,6 @@ gopkg.in/cenkalti/backoff.v1 v1.1.0 h1:Arh75ttbsvlpVA7WtVpH4u9h6Zl46xuptxqLxPiSo gopkg.in/cenkalti/backoff.v1 v1.1.0/go.mod h1:J6Vskwqd+OMVJl8C33mmtxTBs2gyzfv7UDAkHu8BrjI= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= @@ -923,7 +914,6 @@ gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= @@ -935,5 +925,5 @@ modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= -modernc.org/sqlite v1.54.0 h1:JCxR4qwkJvOaqAoYcgDoO25Nc+ROg6EJ2LfBVzdrgog= -modernc.org/sqlite v1.54.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw= +modernc.org/sqlite v1.55.0 h1:hIFh0MCH0rGinQ/4KYb5/UbCkRkb+UP+OkLCVWa5MTM= +modernc.org/sqlite v1.55.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw= diff --git a/handlers/address.go b/handlers/address.go index 4471a1a4c..fb5fbdc59 100644 --- a/handlers/address.go +++ b/handlers/address.go @@ -519,8 +519,11 @@ func loadTransactionsTab(ctx context.Context, pageData *models.AddressPageData, txData.HasTo = true } } - // Deployment is flagged on tx_type at index time (raw recipient was null). - isCreate := tx.TxType&dbtypes.ElTxFlagCreate != 0 + // Deployment is flagged on tx_type at index time (raw recipient was null). A + // transaction that addresses several recipients has none of its own and is not + // one, however much a missing recipient looks like it. + txData.IsMultiTarget = dbtypes.IsMultiTarget(tx.TxType) + isCreate := !txData.IsMultiTarget && tx.TxType&dbtypes.ElTxFlagCreate != 0 // Extract method ID from stored method_id field (first 4 bytes only) if len(tx.MethodID) >= 4 { @@ -975,6 +978,7 @@ var callTypeBitNames = []string{ "CREATE", // 3 "CREATE2", // 4 "SELFDESTRUCT", // 5 + "FRAME", // 6 - a consensus frame of an EIP-8141 transaction, not a traced call } func expandCallTypeMask(mask uint16) []models.AddressPageDataInternalTransactionCallType { diff --git a/handlers/api/slot_inclusion_lists_v1.go b/handlers/api/slot_inclusion_lists_v1.go index 9c9dbd6b9..2cd829411 100644 --- a/handlers/api/slot_inclusion_lists_v1.go +++ b/handlers/api/slot_inclusion_lists_v1.go @@ -5,11 +5,11 @@ import ( "fmt" "net/http" - ethtypes "github.com/ethereum/go-ethereum/core/types" "github.com/ethpandaops/dora/services" "github.com/ethpandaops/go-eth2-client/spec" "github.com/ethpandaops/go-eth2-client/spec/all" "github.com/ethpandaops/go-eth2-client/spec/phase0" + "github.com/ethpandaops/spamoor/txtypes" "github.com/gorilla/mux" "github.com/sirupsen/logrus" ) @@ -93,8 +93,7 @@ func APISlotInclusionListsV1(w http.ResponseWriter, r *http.Request) { if executionPayload != nil { for _, txBytes := range executionPayload.Transactions { - var tx ethtypes.Transaction - if err := tx.UnmarshalBinary(txBytes); err == nil { + if tx, err := txtypes.DecodeTx(txBytes); err == nil { blockTxHashes[string(tx.Hash().Bytes())] = true } } @@ -122,8 +121,8 @@ func APISlotInclusionListsV1(w http.ResponseWriter, r *http.Request) { DataLen: uint64(len(txBytes)), } - var tx ethtypes.Transaction - if err := tx.UnmarshalBinary(txBytes); err != nil { + tx, err := txtypes.DecodeTx(txBytes) + if err != nil { txEntry.DecodeErr = err.Error() } else { txEntry.Hash = fmt.Sprintf("0x%x", tx.Hash().Bytes()) @@ -136,7 +135,7 @@ func APISlotInclusionListsV1(w http.ResponseWriter, r *http.Request) { if v := tx.Value(); v != nil { txEntry.Value = v.String() } - if from, err := ethtypes.Sender(ethtypes.LatestSignerForChainID(tx.ChainId()), &tx); err == nil { + if from, err := tx.From(tx.ChainId()); err == nil { txEntry.From = fmt.Sprintf("0x%x", from.Bytes()) } txEntry.IsIncluded = blockTxHashes[string(tx.Hash().Bytes())] diff --git a/handlers/search.go b/handlers/search.go index 361542bb9..0161b2e14 100644 --- a/handlers/search.go +++ b/handlers/search.go @@ -657,9 +657,10 @@ func buildSearchAheadResult(ctx context.Context, searchType, search string) (*se tx := txs[0] result = &[]models.SearchAheadTransactionResult{ { - TxHash: fmt.Sprintf("0x%x", txHashBytes), - BlockNumber: tx.BlockNumber, - Reverted: tx.RevertID > 0, + TxHash: fmt.Sprintf("0x%x", txHashBytes), + BlockNumber: tx.BlockNumber, + Reverted: txSearchReverted(tx), + FrameIncomplete: txSearchFrameIncomplete(tx), }, } } @@ -741,7 +742,8 @@ func buildTxSearchAheadResults(txs []*dbtypes.ElTransaction) []models.SearchAhea if idx, exists := seen[key]; exists { if tx.BlockNumber > results[idx].BlockNumber { results[idx].BlockNumber = tx.BlockNumber - results[idx].Reverted = tx.RevertID > 0 + results[idx].Reverted = txSearchReverted(tx) + results[idx].FrameIncomplete = txSearchFrameIncomplete(tx) } continue } @@ -750,10 +752,23 @@ func buildTxSearchAheadResults(txs []*dbtypes.ElTransaction) []models.SearchAhea } seen[key] = len(results) results = append(results, models.SearchAheadTransactionResult{ - TxHash: fmt.Sprintf("0x%x", tx.TxHash), - BlockNumber: tx.BlockNumber, - Reverted: tx.RevertID > 0, + TxHash: fmt.Sprintf("0x%x", tx.TxHash), + BlockNumber: tx.BlockNumber, + Reverted: txSearchReverted(tx), + FrameIncomplete: txSearchFrameIncomplete(tx), }) } return results } + +// txSearchReverted reports whether a transaction reverted. A frame transaction never +// does: it reaches the chain only once its validation frames succeed, so a failure +// status on its row means frames within it failed, which txSearchFrameIncomplete reports. +func txSearchReverted(tx *dbtypes.ElTransaction) bool { + return tx.RevertID > 0 && !dbtypes.IsMultiTarget(tx.TxType) +} + +// txSearchFrameIncomplete reports whether a frame transaction had frames fail. +func txSearchFrameIncomplete(tx *dbtypes.ElTransaction) bool { + return tx.RevertID > 0 && dbtypes.IsMultiTarget(tx.TxType) +} diff --git a/handlers/slot.go b/handlers/slot.go index 68afa8b53..6c83f5f11 100644 --- a/handlers/slot.go +++ b/handlers/slot.go @@ -16,7 +16,6 @@ import ( "time" "github.com/ethereum/go-ethereum/common" - ethtypes "github.com/ethereum/go-ethereum/core/types" v1 "github.com/ethpandaops/go-eth2-client/api/v1" "github.com/ethpandaops/go-eth2-client/spec" "github.com/ethpandaops/go-eth2-client/spec/all" @@ -39,6 +38,7 @@ import ( "github.com/ethpandaops/dora/types" "github.com/ethpandaops/dora/types/models" "github.com/ethpandaops/dora/utils" + "github.com/ethpandaops/spamoor/txtypes" ) // Index will return the main "index" page using a go template @@ -1155,6 +1155,7 @@ var slotTxTypeNames = map[uint8]string{ 2: "EIP-1559", 3: "Blob", 4: "EIP-7702", + 6: "Frame", } func getSlotPageTransactions(ctx context.Context, pageData *models.SlotPageBlockData, transactions []bellatrix.Transaction, blockUid uint64) { @@ -1168,9 +1169,7 @@ func getSlotPageTransactions(ctx context.Context, pageData *models.SlotPageBlock sysContracts := services.GlobalBeaconService.GetSystemContractAddresses() for idx, txBytes := range transactions { - var tx ethtypes.Transaction - - err := tx.UnmarshalBinary(txBytes) + tx, err := txtypes.DecodeTx(txBytes) if err != nil { logrus.Warnf("error decoding transaction 0x%x.%v: %v\n", pageData.BlockRoot, idx, err) continue @@ -1198,18 +1197,23 @@ func getSlotPageTransactions(ctx context.Context, pageData *models.SlotPageBlock } txData.DataLen = uint64(len(txData.Data)) - chainId := tx.ChainId() - if chainId != nil && chainId.Cmp(big.NewInt(0)) == 0 { - chainId = nil - } - txFrom, err := ethtypes.Sender(ethtypes.LatestSignerForChainID(chainId), &tx) + txFrom, err := tx.From(tx.ChainId()) if err != nil { logrus.Warnf("error decoding transaction sender 0x%x.%v: %v\n", pageData.BlockRoot, idx, err) } else { txData.From = txFrom.Bytes() } + + // A frame transaction addresses each of its frames separately. What To() reports + // for one is the first SENDER frame's target, which is neither the transaction's + // recipient nor, when it is absent, a contract creation. txTo := tx.To() - if txTo != nil { + + if frameTx, ok := tx.Inner().(*txtypes.FrameTx); ok { + txData.IsMultiTarget = true + txData.FrameCount = uint64(len(frameTx.Frames)) + txTo = nil + } else if txTo != nil { txData.To = txTo.Bytes() } @@ -1217,7 +1221,7 @@ func getSlotPageTransactions(ctx context.Context, pageData *models.SlotPageBlock txHashMap[string(txHash[:])] = txData // check call fn signature - isCreate := txTo == nil + isCreate := txTo == nil && !txData.IsMultiTarget if txData.DataLen >= 4 { // Skip fn signature lookup for deployments, precompiles, and system contracts if skip, altName := utils.ShouldSkipSignatureLookup(txData.To, isCreate, sysContracts); skip { @@ -1791,9 +1795,7 @@ func decodeInclusionListTransactions(il *v1.SignedInclusionList, sysContracts ma txList := make([]*models.SlotPageTransaction, 0, len(il.Message.Transactions)) for idx, txBytes := range il.Message.Transactions { - var tx ethtypes.Transaction - - err := tx.UnmarshalBinary(txBytes) + tx, err := txtypes.DecodeTx(txBytes) if err != nil { logrus.Warnf("error decoding inclusion list transaction %v.%v: %v", il.Message.ValidatorIndex, idx, err) continue @@ -1821,21 +1823,25 @@ func decodeInclusionListTransactions(il *v1.SignedInclusionList, sysContracts ma } txData.DataLen = uint64(len(txData.Data)) - chainId := tx.ChainId() - if chainId != nil && chainId.Cmp(big.NewInt(0)) == 0 { - chainId = nil - } - txFrom, err := ethtypes.Sender(ethtypes.LatestSignerForChainID(chainId), &tx) + txFrom, err := tx.From(tx.ChainId()) if err == nil { txData.From = txFrom.Bytes() } + // A frame transaction addresses each of its frames separately, exactly as on the + // block's own transaction list: To() reports the first SENDER frame's target, + // which is not the recipient, and its absence is not a creation. txTo := tx.To() - if txTo != nil { + + if frameTx, ok := tx.Inner().(*txtypes.FrameTx); ok { + txData.IsMultiTarget = true + txData.FrameCount = uint64(len(frameTx.Frames)) + txTo = nil + } else if txTo != nil { txData.To = txTo.Bytes() } // Check call fn signature - isCreate := txTo == nil + isCreate := txTo == nil && !txData.IsMultiTarget if txData.DataLen >= 4 { if skip, altName := utils.ShouldSkipSignatureLookup(txData.To, isCreate, sysContracts); skip { txData.FuncSigStatus = 10 diff --git a/handlers/slot_download.go b/handlers/slot_download.go index 458662caf..5e3363b51 100644 --- a/handlers/slot_download.go +++ b/handlers/slot_download.go @@ -6,7 +6,6 @@ import ( "fmt" "net/http" - ethtypes "github.com/ethereum/go-ethereum/core/types" "github.com/ethpandaops/dora/blockdb" bdbtypes "github.com/ethpandaops/dora/blockdb/types" "github.com/ethpandaops/dora/services" @@ -15,6 +14,7 @@ import ( "github.com/ethpandaops/go-eth2-client/spec/all" "github.com/ethpandaops/go-eth2-client/spec/bellatrix" "github.com/ethpandaops/go-eth2-client/spec/phase0" + "github.com/ethpandaops/spamoor/txtypes" "github.com/golang/snappy" dynssz "github.com/pk910/dynamic-ssz" ) @@ -251,8 +251,8 @@ func handleBlockBodyDownload(w http.ResponseWriter, blockData *services.Combined block.Transactions = make([]json.RawMessage, 0, len(transactions)) for i, txBytes := range transactions { - var tx ethtypes.Transaction - if err := tx.UnmarshalBinary(txBytes); err != nil { + tx, err := txtypes.DecodeTx(txBytes) + if err != nil { return fmt.Errorf("failed to decode tx %d: %w", i, err) } @@ -272,11 +272,7 @@ func handleBlockBodyDownload(w http.ResponseWriter, blockData *services.Combined txMap["transactionIndex"] = fmt.Sprintf("0x%x", i) // Recover sender address. - chainID := tx.ChainId() - if chainID != nil && chainID.Sign() == 0 { - chainID = nil - } - if from, err := ethtypes.Sender(ethtypes.LatestSignerForChainID(chainID), &tx); err == nil { + if from, err := tx.From(tx.ChainId()); err == nil { txMap["from"] = fmt.Sprintf("0x%x", from[:]) } @@ -331,6 +327,20 @@ type receiptJSON struct { Status string `json:"status"` BlobGasUsed string `json:"blobGasUsed,omitempty"` BlobGasPrice string `json:"blobGasPrice,omitempty"` + + // EIP-8141 frame transactions only: the account that settled the fee, and one result + // per frame. A frame transaction has no transaction-level status in the consensus + // receipt, and its "to" is the sender rather than a recipient. + Payer *string `json:"payer,omitempty"` + FrameReceipts []*frameReceiptJSON `json:"frameReceipts,omitempty"` +} + +// frameReceiptJSON is one frame's result within a frame transaction's receipt. +type frameReceiptJSON struct { + Status string `json:"status"` + GasUsed string `json:"gasUsed"` + StateGasUsed string `json:"stateGasUsed"` + Logs []*logJSON `json:"logs"` } // logJSON matches the log entry format in eth_getTransactionReceipt. @@ -438,8 +448,8 @@ func buildReceiptsFromFullBlob( receipts := make([]*receiptJSON, 0, len(transactions)) for i, txBytes := range transactions { - var tx ethtypes.Transaction - if err := tx.UnmarshalBinary(txBytes); err != nil { + tx, err := txtypes.DecodeTx(txBytes) + if err != nil { return nil, fmt.Errorf("failed to decode tx %d: %w", i, err) } @@ -495,8 +505,8 @@ func buildSingleReceipt( return nil, fmt.Errorf("failed to decompress receipt meta: %w", err) } - var meta bdbtypes.ReceiptMetaData - if err := dynssz.GetGlobalDynSsz().UnmarshalSSZ(&meta, metaRaw); err != nil { + meta, frameData, err := bdbtypes.DecodeReceiptMetaSection(metaRaw) + if err != nil { return nil, fmt.Errorf("failed to decode receipt meta: %w", err) } @@ -571,6 +581,40 @@ func buildSingleReceipt( } } + // Frame transaction content. The transaction's logs are the per-frame lists + // concatenated in frame order, so the per-frame counts partition the flat list back + // into the frames that emitted them. + if frameData != nil { + payer := fmt.Sprintf("0x%x", frameData.Payer[:]) + receipt.Payer = &payer + receipt.FrameReceipts = make([]*frameReceiptJSON, 0, len(frameData.Frames)) + + logOffset := 0 + + for i := range frameData.Frames { + frame := &frameData.Frames[i] + + logEnd := logOffset + int(frame.LogCount) + if logEnd > len(receipt.Logs) { + logEnd = len(receipt.Logs) + } + + frameLogs := []*logJSON{} + if logOffset < logEnd { + frameLogs = receipt.Logs[logOffset:logEnd] + } + + logOffset = logEnd + + receipt.FrameReceipts = append(receipt.FrameReceipts, &frameReceiptJSON{ + Status: fmt.Sprintf("0x%x", frame.Status), + GasUsed: fmt.Sprintf("0x%x", frame.ExecGasUsed), + StateGasUsed: fmt.Sprintf("0x%x", frame.StateGasUsed), + Logs: frameLogs, + }) + } + } + return receipt, nil } diff --git a/handlers/transaction.go b/handlers/transaction.go index 7b6fd485a..c7e962b46 100644 --- a/handlers/transaction.go +++ b/handlers/transaction.go @@ -31,6 +31,7 @@ import ( "github.com/ethpandaops/dora/types" "github.com/ethpandaops/dora/types/models" "github.com/ethpandaops/dora/utils" + "github.com/ethpandaops/spamoor/txtypes" ) // EIP-7708: ETH Transfer logger address — emits Transfer(address,address,uint256) on every ETH move. @@ -43,6 +44,7 @@ var txTypeNames = map[uint8]string{ 2: "Dynamic Fee (EIP-1559)", 3: "Blob (EIP-4844)", 4: "Set Code (EIP-7702)", + 6: "Frame (EIP-8141)", } // Transaction handles the /tx/{hash} page @@ -55,6 +57,8 @@ func Transaction(w http.ResponseWriter, r *http.Request) { "transaction/internaltxs.html", "transaction/authorizations.html", "transaction/blobs.html", + "transaction/frames.html", + "transaction/signatures.html", ) notfoundTemplateFiles := append(layoutTemplateFiles, "transaction/notfound.html", @@ -174,20 +178,20 @@ func buildTransactionPageData(ctx context.Context, txHash []byte, tabView string if time.Since(blockTime) > 5*time.Minute { cacheTimeout = 15 * time.Minute } - setTransactionEnsNames(ctx, pageData) + finalizeTransactionPage(ctx, pageData) return pageData, cacheTimeout } // Not in DB - reconstruct from blockdb (relational row pruned but still // within the longer blockdb/details retention). if buildTransactionPageDataFromBlockdb(ctx, pageData, txHash, chainState) { - setTransactionEnsNames(ctx, pageData) + finalizeTransactionPage(ctx, pageData) return pageData, 15 * time.Minute } // Not in DB or blockdb - try to fetch from EL client if buildTransactionPageDataFromEL(ctx, pageData, txHash, chainState) { - setTransactionEnsNames(ctx, pageData) + finalizeTransactionPage(ctx, pageData) return pageData, 30 * time.Minute } @@ -197,6 +201,53 @@ func buildTransactionPageData(ctx context.Context, txHash []byte, tabView string return pageData, 1 * time.Minute } +// finalizeTransactionPage does the work that depends on the whole page being built, +// whichever source it came from. +func finalizeTransactionPage(ctx context.Context, pageData *models.TransactionPageData) { + // Naming what each frame calls costs a signature lookup, so it is done for the tab + // that shows the calls rather than on every view of the transaction. + if pageData.TabView == "frames" && len(pageData.Frames) > 0 { + resolveFrameCalldata(ctx, pageData) + } + + applyExpiryMargin(pageData) + applySignatureRoles(pageData) + setTransactionEnsNames(ctx, pageData) +} + +// applyExpiryMargin states an expiry deadline against the time the transaction was +// included rather than against now. +// +// The expiry verifier frame checks the deadline when the transaction executes, so once it +// is on chain the only thing the deadline says is how much room it had left. Counting +// down to it from the present says nothing: a transaction included an hour ago with a +// thirty minute deadline was never late, and reading "expired 30 min. ago" would suggest +// it was. +func applyExpiryMargin(pageData *models.TransactionPageData) { + if !pageData.HasExpiry || pageData.BlockTime.IsZero() { + return + } + + margin := pageData.ExpiryTime.Sub(pageData.BlockTime) + + pageData.ExpiryPassed = margin < 0 + pageData.ExpiryMargin = shortDuration(margin.Abs()) +} + +// shortDuration renders a duration at the coarsest unit that still says something. +func shortDuration(d time.Duration) string { + switch { + case d < time.Minute: + return fmt.Sprintf("%d sec.", int(d.Seconds())) + case d < time.Hour: + return fmt.Sprintf("%d min.", int(d.Minutes())) + case d < 24*time.Hour: + return fmt.Sprintf("%d hr.", int(d.Hours())) + default: + return fmt.Sprintf("%d days", int(d.Hours()/24)) + } +} + // setTransactionEnsNames collects every execution address shown on the transaction // detail page (main from/to plus the events, token-transfer, internal-tx, access-list, // state-change and authorization sub-lists of the active tab) and resolves their ENS @@ -222,6 +273,13 @@ func setTransactionEnsNames(ctx context.Context, pageData *models.TransactionPag for _, auth := range pageData.Authorizations { ensAddrs = append(ensAddrs, auth.AuthorityAddr, auth.DelegateAddr) } + for _, frame := range pageData.Frames { + ensAddrs = append(ensAddrs, frame.TargetAddr, frame.CallerAddr) + } + for _, sig := range pageData.Signatures { + ensAddrs = append(ensAddrs, sig.SignerAddr) + } + ensAddrs = append(ensAddrs, pageData.PayerAddr, pageData.FeeRecipientAddr) pageData.SetEnsNames(resolveEnsNames(ctx, ensAddrs)) } @@ -388,7 +446,10 @@ func buildTransactionPageDataFromDB(ctx context.Context, pageData *models.Transa pageData.ToIsContract = toAccount.IsContract pageData.HasTo = true } - } else { + } else if !dbtypes.IsMultiTarget(tx.TxType) { + // No recipient means a contract creation - unless the transaction addresses + // several, in which case it has none of its own and the row's to_id is the 0 + // that says so. pageData.IsCreate = true } @@ -422,12 +483,12 @@ func buildTransactionPageDataFromDB(ctx context.Context, pageData *models.Transa pageData.GasUsedPct = float64(tx.GasUsed) / float64(tx.GasLimit) * 100 } - // Transaction details - pageData.TxType = tx.TxType - if name, ok := txTypeNames[tx.TxType]; ok { + // Transaction details. The create flag shares the byte with the type. + pageData.TxType = tx.TxType & dbtypes.ElTxTypeMask + if name, ok := txTypeNames[pageData.TxType]; ok { pageData.TxTypeName = name } else { - pageData.TxTypeName = fmt.Sprintf("Type %d", tx.TxType) + pageData.TxTypeName = fmt.Sprintf("Type %d", pageData.TxType) } pageData.Nonce = tx.Nonce pageData.TxIndex = uint32(tx.TxUid & 0xFFFF) @@ -448,14 +509,43 @@ func buildTransactionPageDataFromDB(ctx context.Context, pageData *models.Transa // Blobs pageData.BlobCount = tx.BlobCount - // Check data_status for this block (for blockdb availability) + // Check data_status for this block (for blockdb availability), and who the block paid + // its transaction fees to, so a balance moving for that reason can say so. if elBlock, err := db.GetElBlock(ctx, tx.BlockUid); err == nil { pageData.DataStatus = elBlock.DataStatus + + if elBlock.FeeAccountID != nil && *elBlock.FeeAccountID != 0 { + if accounts, err := db.GetElAccountsByIDs(ctx, []uint64{*elBlock.FeeAccountID}); err == nil && len(accounts) > 0 { + pageData.FeeRecipientAddr = accounts[0].Address + } + } } // A call trace exists for this tx whenever its block stored call traces, even // if it has only the single root frame (no internal calls aggregated). pageData.HasTrace = pageData.DataStatus&dbtypes.ElBlockDataCallTraces != 0 + // The state diff is stored independently of the call trace: a trace that cannot be + // reconciled with a frame transaction's frames is discarded, and the diff it came + // with is not. + pageData.HasStateChanges = pageData.DataStatus&dbtypes.ElBlockDataStateChanges != 0 + + // Frames of a frame transaction. They are the transaction's recipients, values and + // statuses, so they belong on the overview rather than behind a tab that has to be + // opened. The frames themselves come from the transaction, which loadFullTransactionData + // has already read; the payer and the per-frame results are on the receipt, which + // blockdb keeps. + if dbtypes.IsMultiTarget(tx.TxType) { + pageData.IsFrameTx = true + + if displayBlock != nil { + loadFrameReceiptFromBlockdb(ctx, pageData, displayBlock.Slot, displayBlock.Root, tx.TxHash) + } + + // With the per-frame results, the status comes from them; without, from what the + // row says about them. Either way it is not the revert the row's status reads as. + applyFrameTxStatus(pageData) + } + // Event count comes straight off the tx row (logs emitted); full event // data is loaded from blockdb when the events tab is opened. pageData.EventCount = uint64(tx.EventCount) @@ -466,8 +556,13 @@ func buildTransactionPageDataFromDB(ctx context.Context, pageData *models.Transa transferCount, _ := db.GetElTokenTransferCountByTxUid(ctx, tx.TxUid) pageData.TokenTransferCount = transferCount - internalTxCount, _ := db.GetElTransactionsInternalCountByTxUid(ctx, tx.TxUid) - pageData.InternalTxCount = internalTxCount + // A frame transaction's per-account rows are built from its frames rather than from a + // call trace, so counting them would put a call count on the tab that is really a + // count of accounts the frames touched. The tab reports the real number once loaded. + if !pageData.IsFrameTx { + internalTxCount, _ := db.GetElTransactionsInternalCountByTxUid(ctx, tx.TxUid) + pageData.InternalTxCount = internalTxCount + } // Load tab-specific detailed data. Full row data is only loaded for // the active tab to avoid unnecessary I/O. Events and internal txs @@ -478,13 +573,15 @@ func buildTransactionPageDataFromDB(ctx context.Context, pageData *models.Transa case "transfers": transfers, _ := db.GetElTokenTransfersByTxUid(ctx, tx.TxUid) loadTransactionTransfersFromData(ctx, pageData, transfers) + attributeTokenTransfersToFrames(pageData) case "internaltxs": loadTransactionInternalTxsFromBlockdb(ctx, pageData, tx.BlockUid, tx.TxUid) computeInternalTxIndent(pageData) case "statechanges": loadTransactionStateChangesFromBlockdb(ctx, pageData, tx.BlockUid) + annotateStateChangeRoles(pageData) case "authorizations": - if pageData.TxType == ethtypes.SetCodeTxType && len(pageData.Authorizations) > 0 { + if pageData.TxType == txtypes.SetCodeTxType && len(pageData.Authorizations) > 0 { resolveAuthorizationValidity(ctx, pageData, tx.BlockUid) } } @@ -507,7 +604,7 @@ func buildTransactionPageDataFromEL(ctx context.Context, pageData *models.Transa defer cancel() // Try to fetch transaction from EL client - var ethTx *ethtypes.Transaction + var ethTx *txtypes.Transaction var isPending bool var err error @@ -519,12 +616,7 @@ func buildTransactionPageDataFromEL(ctx context.Context, pageData *models.Transa continue } - ethClient := rpcClient.GetEthClient() - if ethClient == nil { - continue - } - - ethTx, isPending, err = ethClient.TransactionByHash(ctx, txHashCommon) + ethTx, isPending, err = rpcClient.GetTransactionByHash(ctx, txHashCommon) if err == nil && ethTx != nil { break } @@ -558,17 +650,14 @@ func buildTransactionPageDataFromEL(ctx context.Context, pageData *models.Transa continue } - ethClient := rpcClient.GetEthClient() - if ethClient == nil { - continue - } - - receipt, err := ethClient.TransactionReceipt(ctx, txHashCommon) + receipt, err := rpcClient.GetTransactionReceipt(ctx, txHashCommon) if err == nil && receipt != nil { // Receipt found - upgrade to full view mode pageData.ViewMode = models.TxViewModeFull pageData.HasReceipt = true + applyFrameReceiptExtra(pageData, receipt) + // Status pageData.Status = receipt.Status == 1 if pageData.Status { @@ -577,6 +666,11 @@ func buildTransactionPageDataFromEL(ctx context.Context, pageData *models.Transa pageData.StatusText = "Failed" } + // A frame transaction's status is derived from its frames, not taken from + // the client's own derived one, so it is stated after the generic status + // rather than before it. + applyFrameTxStatus(pageData) + // Gas used pageData.GasUsed = receipt.GasUsed if pageData.GasLimit > 0 { @@ -651,7 +745,7 @@ func buildTransactionPageDataFromEL(ctx context.Context, pageData *models.Transa // applyEthTxFields populates the page-data fields derived from a parsed // transaction envelope. Shared by the EL-client and blockdb-reconstruction paths. -func applyEthTxFields(ctx context.Context, pageData *models.TransactionPageData, ethTx *ethtypes.Transaction) { +func applyEthTxFields(ctx context.Context, pageData *models.TransactionPageData, ethTx *txtypes.Transaction) { pageData.TxType = ethTx.Type() if name, ok := txTypeNames[ethTx.Type()]; ok { pageData.TxTypeName = name @@ -680,14 +774,26 @@ func applyEthTxFields(ctx context.Context, pageData *models.TransactionPageData, pageData.TipPrice = tipFloat / 1e9 } - if from, err := ethtypes.Sender(ethtypes.LatestSignerForChainID(ethTx.ChainId()), ethTx); err == nil { + if from, err := ethTx.From(ethTx.ChainId()); err == nil { pageData.FromAddr = from.Bytes() } - if ethTx.To() != nil { + buildTxSignature(pageData, ethTx) + + // A frame transaction reports the first SENDER frame's target, which is one of + // several and not the transaction's recipient - and its absence is not a creation. + switch { + case ethTx.Type() == txtypes.FrameTxType: + pageData.IsFrameTx = true + + if frameTx, ok := ethTx.Inner().(*txtypes.FrameTx); ok { + buildFramesFromEnvelope(pageData, frameTx) + applyFrameTxEnvelope(pageData, frameTx) + } + case ethTx.To() != nil: pageData.ToAddr = ethTx.To().Bytes() pageData.HasTo = true - } else { + default: pageData.IsCreate = true } @@ -706,10 +812,10 @@ func applyEthTxFields(ctx context.Context, pageData *models.TransactionPageData, pageData.BlobCount = uint32(len(ethTx.BlobHashes())) - if ethTx.Type() == ethtypes.SetCodeTxType { + if ethTx.Type() == txtypes.SetCodeTxType { loadAuthorizationData(pageData, ethTx) } - if ethTx.Type() == ethtypes.AccessListTxType { + if ethTx.Type() == txtypes.AccessListTxType { loadAccessListData(pageData, ethTx) } } @@ -772,8 +878,8 @@ func buildTransactionPageDataFromBlockdb(ctx context.Context, pageData *models.T } rlpData := execTxs[txIndex] - var ethTx ethtypes.Transaction - if err := ethTx.UnmarshalBinary(rlpData); err != nil { + ethTx, err := txtypes.DecodeTx(rlpData) + if err != nil { continue } if !bytes.Equal(ethTx.Hash().Bytes(), txHash) { @@ -782,9 +888,9 @@ func buildTransactionPageDataFromBlockdb(ctx context.Context, pageData *models.T // Match found - reconstruct the page from the envelope. pageData.ViewMode = models.TxViewModePartial - applyEthTxFields(ctx, pageData, ðTx) + applyEthTxFields(ctx, pageData, ethTx) pageData.TxRLP = "0x" + hex.EncodeToString(rlpData) - generateTxJSON(pageData, ðTx) + generateTxJSON(pageData, ethTx) // Block info. pageData.Slot = block.Slot @@ -815,9 +921,10 @@ func buildTransactionPageDataFromBlockdb(ctx context.Context, pageData *models.T // Receipt metadata from blockdb (upgrades to full view if available). applyReceiptMetaFromBlockdb(ctx, pageData, block.Slot, block.Root, txHash) - // Blob data for type 3 (blob) transactions. - if ethTx.Type() == 3 && len(ethTx.BlobHashes()) > 0 { - loadBlobData(pageData, ðTx, blockData) + // Blob data. A frame transaction may carry blobs too, so what decides this is + // whether the transaction has any, not which type it is. + if len(ethTx.BlobHashes()) > 0 { + loadBlobData(pageData, ethTx, blockData) } return true @@ -846,11 +953,17 @@ func applyReceiptMetaFromBlockdb(ctx context.Context, pageData *models.Transacti if err != nil { return } - var meta bdbtypes.ReceiptMetaData - if err := dynssz.GetGlobalDynSsz().UnmarshalSSZ(&meta, metaRaw); err != nil { + + meta, frameData, err := bdbtypes.DecodeReceiptMetaSection(metaRaw) + if err != nil { return } + if frameData != nil { + applyFramePayer(pageData, frameData) + applyFrameResults(pageData, frameData.Frames) + } + pageData.ViewMode = models.TxViewModeFull pageData.HasReceipt = true @@ -861,6 +974,10 @@ func applyReceiptMetaFromBlockdb(ctx context.Context, pageData *models.Transacti pageData.StatusText = "Failed" } + // As on the EL path: a frame transaction's own status comes from its frames and has + // to be stated after the generic one, or the client's derived status wins. + applyFrameTxStatus(pageData) + pageData.GasUsed = meta.GasUsed if pageData.GasLimit > 0 { pageData.GasUsedPct = float64(meta.GasUsed) / float64(pageData.GasLimit) * 100 @@ -880,7 +997,7 @@ func applyReceiptMetaFromBlockdb(ctx context.Context, pageData *models.Transacti } // generateTxJSON creates a JSON representation of the transaction using proper marshaling. -func generateTxJSON(pageData *models.TransactionPageData, ethTx *ethtypes.Transaction) { +func generateTxJSON(pageData *models.TransactionPageData, ethTx *txtypes.Transaction) { // Use the transaction's built-in MarshalJSON for standardized format jsonBytes, err := ethTx.MarshalJSON() if err != nil { @@ -940,6 +1057,8 @@ func loadTransactionEventsFromBlockdb(ctx context.Context, pageData *models.Tran } else { pageData.Events = buildEventsFromBlockdb(events) pageData.EventCount = uint64(len(pageData.Events)) + attributeEventsToFrames(pageData) + return } } @@ -1068,6 +1187,25 @@ func loadTransactionStateChangesFromBlockdb(ctx context.Context, pageData *model pageData.StateChanges = buildStateChangesFromBlockdb(accounts) } +// annotateStateChangeRoles marks the accounts whose balance moved for a reason the +// numbers do not show: the sender for what it spent, the fee recipient for what the block +// paid it, and a frame transaction's payer for a fee its sender did not owe. +func annotateStateChangeRoles(pageData *models.TransactionPageData) { + sameAddress := func(a, b []byte) bool { + return len(a) > 0 && len(b) > 0 && bytes.Equal(a, b) + } + + for _, account := range pageData.StateChanges { + account.IsSender = sameAddress(account.Address, pageData.FromAddr) + account.IsFeeRecipient = sameAddress(account.Address, pageData.FeeRecipientAddr) + + // The sender paying its own fee is the ordinary case and says nothing. + account.IsPayer = !pageData.PayerIsSender && sameAddress(account.Address, pageData.PayerAddr) + + account.PredeployName = framePredeployNames[common.BytesToAddress(account.Address)] + } +} + func buildStateChangesFromBlockdb(accounts []bdbtypes.StateChangeAccount) []*models.TransactionPageDataStateChangeAccount { result := make([]*models.TransactionPageDataStateChangeAccount, 0, len(accounts)) @@ -1193,6 +1331,8 @@ func loadTransactionInternalTxsFromBlockdb(ctx context.Context, pageData *models } else { buildInternalTxsFromBlockdb(ctx, pageData, frames) pageData.InternalTxCount = uint64(len(pageData.InternalTxs)) + attributeInternalTxsToFrames(pageData) + return } } @@ -1203,7 +1343,20 @@ func loadTransactionInternalTxsFromBlockdb(ctx context.Context, pageData *models // No per-call detail in the DB index (it stores per-account aggregates), // so when blockdb is unavailable we have nothing to render. Surface the // "not available" state so the template shows the archive notice. + // + // For a frame transaction the block's trace was stored - the check above passed - + // and this transaction still has none, which means the client did not decompose it + // into its frames. That is a statement about the client, not about the data being + // gone, and the count taken from the per-account rows is not a call count either. _ = txUid + + if pageData.IsFrameTx { + pageData.FrameCallsNotTraced = true + pageData.InternalTxCount = 0 + + return + } + pageData.InternalTxsNotAvailable = true } @@ -1402,6 +1555,7 @@ func loadTransactionTransfersFromData(ctx context.Context, pageData *models.Tran for i, t := range transfers { transfer := &models.TransactionPageDataTokenTransfer{ TransferIndex: uint32(i), + EventIndex: t.TxIdx, TokenID: t.TokenID, TokenType: t.TokenType, Amount: t.Amount, @@ -1476,8 +1630,8 @@ func loadFullTransactionData(ctx context.Context, pageData *models.TransactionPa pageData.TxRLP = "0x" + hex.EncodeToString(rlpData) // Parse the transaction to get input data and JSON representation - var ethTx ethtypes.Transaction - if err := ethTx.UnmarshalBinary(rlpData); err != nil { + ethTx, err := txtypes.DecodeTx(rlpData) + if err != nil { logrus.WithError(err).Debug("failed to parse transaction RLP") return } @@ -1492,28 +1646,41 @@ func loadFullTransactionData(ctx context.Context, pageData *models.TransactionPa } // Generate JSON using proper marshaling - generateTxJSON(pageData, ðTx) + generateTxJSON(pageData, ethTx) + + buildTxSignature(pageData, ethTx) - // Load blob data for type 3 transactions - if ethTx.Type() == 3 && len(ethTx.BlobHashes()) > 0 { - loadBlobData(pageData, ðTx, blockData) + // A frame transaction's nonce domain and expiry deadline live in the envelope, so + // they are only available while the block it came in is still retained. + if frameTx, ok := ethTx.Inner().(*txtypes.FrameTx); ok { + if len(pageData.Frames) == 0 { + buildFramesFromEnvelope(pageData, frameTx) + } + + applyFrameTxEnvelope(pageData, frameTx) + } + + // Load blob data. EIP-8141 gives a frame transaction blob hashes and a blob fee cap + // of its own, so the type is not what decides whether there are blobs to show. + if len(ethTx.BlobHashes()) > 0 { + loadBlobData(pageData, ethTx, blockData) } // Load authorization data for type 4 (EIP-7702) transactions - if ethTx.Type() == ethtypes.SetCodeTxType { - loadAuthorizationData(pageData, ðTx) + if ethTx.Type() == txtypes.SetCodeTxType { + loadAuthorizationData(pageData, ethTx) } // Load access list data for type 1 (EIP-2930) transactions - if ethTx.Type() == ethtypes.AccessListTxType { - loadAccessListData(pageData, ðTx) + if ethTx.Type() == txtypes.AccessListTxType { + loadAccessListData(pageData, ethTx) } } // loadBlobData populates blob-related data for type 3 (blob) transactions. // It extracts versioned hashes from the transaction, KZG commitments from the beacon block, // and calculates blob gas fees. -func loadBlobData(pageData *models.TransactionPageData, ethTx *ethtypes.Transaction, blockData *services.CombinedBlockResponse) { +func loadBlobData(pageData *models.TransactionPageData, ethTx *txtypes.Transaction, blockData *services.CombinedBlockResponse) { blobHashes := ethTx.BlobHashes() if len(blobHashes) == 0 { return @@ -1649,9 +1816,9 @@ func applyCalldataCosts(pageData *models.TransactionPageData) { // parsed transaction and populates pageData.Authorizations. func loadAuthorizationData( pageData *models.TransactionPageData, - ethTx *ethtypes.Transaction, + ethTx *txtypes.Transaction, ) { - authList := ethTx.SetCodeAuthorizations() + authList := ethTx.AuthList() if len(authList) == 0 { return } @@ -1682,7 +1849,7 @@ func loadAuthorizationData( // pageData.AccessListStorageKeys. func loadAccessListData( pageData *models.TransactionPageData, - ethTx *ethtypes.Transaction, + ethTx *txtypes.Transaction, ) { al := ethTx.AccessList() if len(al) == 0 { @@ -1786,3 +1953,1138 @@ func resolveAuthorizationValidity( } } } + +// frameModeNames names the caller each frame mode runs under. +var frameModeNames = map[uint8]string{ + uint8(txtypes.FrameModeDefault): "Default", + uint8(txtypes.FrameModeVerify): "Verify", + uint8(txtypes.FrameModeSender): "Sender", + uint8(txtypes.FrameModePostTx): "Post-tx", +} + +// frameSpeciesNames turn the mempool rules' species names into readable ones. +// framePredeployNames names the addresses EIP-8141 gives a role to, so a frame that +// calls one reads as the protocol step it is rather than as an unknown account. +var framePredeployNames = map[common.Address]string{ + txtypes.EntryPoint: "ENTRY_POINT", + txtypes.ExpiryVerifier: "EXPIRY_VERIFIER", + txtypes.NonceManager: "NONCE_MANAGER", + txtypes.RecentRootAddress: "RECENT_ROOTS", +} + +var frameSpeciesNames = map[txtypes.FrameSpecies]string{ + txtypes.SpeciesSelfVerify: "Self verify", + txtypes.SpeciesOnlyVerify: "Execution check", + txtypes.SpeciesPay: "Paymaster", + txtypes.SpeciesExpiryVerify: "Expiry check", + txtypes.SpeciesDeploy: "Account deploy", + txtypes.SpeciesUserOp: "User operation", + txtypes.SpeciesPostOp: "Settlement", + txtypes.SpeciesPostTx: "Assertion", + txtypes.SpeciesOther: "Other", +} + +// frameSpeciesInfo says what each kind of frame is for. The name on the badge is short +// enough to scan a list by; this is what it means. +var frameSpeciesInfo = map[txtypes.FrameSpecies]string{ + txtypes.SpeciesSelfVerify: "Runs the sender's own validation code and approves both execution and payment. " + + "This is the self-relayed case: the sender vouches for the transaction and pays for it itself.", + + txtypes.SpeciesOnlyVerify: "Runs the sender's own validation code and approves execution, but not payment. " + + "Someone else settles the fee, in a paymaster frame that follows.", + + txtypes.SpeciesPay: "A paymaster approves payment, which makes it the account charged for the transaction " + + "rather than the sender. Its own signature entry on the transaction is what authorises that.", + + txtypes.SpeciesExpiryVerify: "Calls the expiry verifier predeploy with a deadline. It reverts once the deadline " + + "has passed, and a reverting validation frame makes the whole transaction invalid - which is what keeps a " + + "stale transaction off the chain.", + + txtypes.SpeciesDeploy: "Deploys code to the sender's account before anything validates it, so an account that " + + "does not exist yet can be used by the same transaction that creates it. It has to lead the validation prefix.", + + txtypes.SpeciesUserOp: "One of the calls the transaction was sent to make. SENDER frames are entered by the " + + "sender itself, and are the only ones that may carry value.", + + txtypes.SpeciesPostOp: "A call made after the operations have run, entered by the ENTRY_POINT predeploy rather " + + "than by the sender. It is where a paymaster squares up once the real cost is known.", + + txtypes.SpeciesPostTx: "Runs after the transaction's operations, reading what they did through TXTRACE and " + + "asserting something about it. If it reverts, the whole execution body is reverted with it - not just " + + "its own atomic batch - though the transaction still reaches the chain and its fee is still owed.", + + txtypes.SpeciesOther: "The frame's mode and approval flags match none of the shapes the mempool rules name.", +} + +// frameStatusText renders a frame's result. Skipped is neither a success nor a failure - +// an earlier frame in its atomic batch failed and this one never ran - and a frame the +// client reported no result for is neither either. +func frameStatusText(status uint8, rolledBack bool) string { + switch uint64(status) { + case txtypes.FrameStatusSuccess: + if rolledBack { + return "Rolled back" + } + + return "Success" + case txtypes.FrameStatusFailed: + return "Failed" + case txtypes.FrameStatusSkipped: + return "Skipped" + default: + return "Unknown" + } +} + +// buildFramesFromEnvelope builds the frame list from the transaction itself, for the paths +// that have no relational rows to read from: a transaction reconstructed from blockdb +// after its rows were pruned, or one fetched straight from a client. +// +// The results each frame produced are not in the envelope and are overlaid separately. +func buildFramesFromEnvelope(pageData *models.TransactionPageData, frameTx *txtypes.FrameTx) { + frames := make([]*models.TransactionPageDataFrame, 0, len(frameTx.Frames)) + + validationLen := frameTx.ValidationPrefixLength() + + for i, protocolFrame := range frameTx.Frames { + target := protocolFrame.ResolvedTarget(frameTx.Sender) + caller := frameCaller(protocolFrame, frameTx.Sender) + species := frameSpecies(protocolFrame, frameTx.Sender, i < validationLen) + + frame := &models.TransactionPageDataFrame{ + Index: uint32(i), + Mode: uint8(protocolFrame.Mode), + ModeName: frameModeNames[uint8(protocolFrame.Mode)], + Species: frameSpeciesNames[species], + SpeciesInfo: frameSpeciesInfo[species], + Flags: protocolFrame.Flags, + ApprovesPayment: protocolFrame.Flags&txtypes.ApprovePayment != 0, + ApprovesExecution: protocolFrame.Flags&txtypes.ApproveExecution != 0, + AtomicBatch: protocolFrame.IsAtomicBatch(), + IsValidation: i < validationLen, + CallerAddr: caller.Bytes(), + CallerIsSender: caller == frameTx.Sender, + CallerLabel: framePredeployNames[caller], + TargetAddr: target.Bytes(), + HasTarget: true, + TargetIsSender: target == frameTx.Sender, + TargetLabel: framePredeployNames[target], + DataLen: uint32(len(protocolFrame.Data)), + Data: protocolFrame.Data, + ExecGasLimit: protocolFrame.Limits.Execution, + StateGasLimit: protocolFrame.Limits.State, + Status: bdbtypes.FrameStatusUnknown, + StatusText: frameStatusText(bdbtypes.FrameStatusUnknown, false), + BatchFailedIndex: -1, + } + + if frame.ModeName == "" { + frame.ModeName = "Unknown" + } + + if protocolFrame.Value != nil { + frame.Amount = weiToEth(protocolFrame.Value.ToBig()) + } + + if len(protocolFrame.Data) >= 4 { + frame.MethodID = protocolFrame.Data[:4] + } + + frames = append(frames, frame) + } + + assignFrameBatches(frames) + + pageData.Frames = frames + pageData.FrameCount = uint64(len(frames)) + pageData.FrameShape = frameShapeLabel(frameTx) + pageData.FrameValidationCount = validationLen + + // The envelope declares the frames; what each of them did is on the receipt, which is + // overlaid separately and may not have been kept. + pageData.FrameResultsMissing = true +} + +// applyFrameResults overlays the result a receipt reports for each frame. +// +// The receipt reports one result per frame, so it also establishes how many frames the +// transaction had. Where the transaction envelope was unavailable - its block is no +// longer retained - the results are all there is, and the frames are raised from them so +// the page still says what ran, without the targets and budgets only the envelope holds. +func applyFrameResults(pageData *models.TransactionPageData, results []bdbtypes.FrameReceiptEntry) { + if len(results) == 0 { + return + } + + for len(pageData.Frames) < len(results) { + index := len(pageData.Frames) + pageData.Frames = append(pageData.Frames, &models.TransactionPageDataFrame{ + Index: uint32(index), + ModeName: "Unknown", + BatchIndex: index, + BatchSize: 1, + BatchFailedIndex: -1, + }) + } + + pageData.FrameCount = uint64(len(pageData.Frames)) + + for i, result := range results { + frame := pageData.Frames[i] + frame.Status = result.Status + frame.StatusText = frameStatusText(result.Status, frame.RolledBack) + frame.ExecGasUsed = result.ExecGasUsed + frame.StateGasUsed = result.StateGasUsed + + logCount := result.LogCount + if logCount > 0xffff { + logCount = 0xffff + } + + frame.LogCount = uint16(logCount) + } + + // A frame the receipt did not reach keeps no result rather than a stale one. + for i := len(results); i < len(pageData.Frames); i++ { + frame := pageData.Frames[i] + frame.Status = bdbtypes.FrameStatusUnknown + frame.StatusText = frameStatusText(bdbtypes.FrameStatusUnknown, false) + } + + pageData.FrameResultsMissing = false + + markRolledBackFrames(pageData.Frames) + applyFrameBodyReverted(pageData) + summarizeFrames(pageData) +} + +// applyFrameReceiptExtra overlays the frame content a client's receipt reports. +func applyFrameReceiptExtra(pageData *models.TransactionPageData, receipt *txtypes.Receipt) { + extra := receipt.FrameExtra() + if extra == nil { + return + } + + applyFramePayer(pageData, &bdbtypes.FrameReceiptData{Payer: extra.Payer}) + + results := make([]bdbtypes.FrameReceiptEntry, 0, len(extra.Frames)) + for _, frame := range extra.Frames { + results = append(results, bdbtypes.FrameReceiptEntry{ + Status: uint8(frame.Status), + ExecGasUsed: frame.ExecutionGas, + StateGasUsed: frame.StateGas, + LogCount: uint32(len(frame.Logs)), + }) + } + + applyFrameResults(pageData, results) +} + +// markRolledBackFrames flags the frames whose effects did not survive the transaction. +// +// A frame's status says whether it ran, not whether what it did lasted. Two rules discard +// the effects of a frame that reports success: an atomic batch that fails is unrolled, +// and a failing POST_TX frame reverts the whole execution body. Both live in txtypes, +// which owns the rules, so the shapes and statuses the page already holds are handed back +// to it rather than the rules being restated here. +func markRolledBackFrames(frames []*models.TransactionPageDataFrame) { + durable := frameDurability(frames) + + // A batch names what undid it, which the durability answer alone does not carry. + undoneBy := batchFailures(frames) + + for i, frame := range frames { + if durable[i] || uint64(frame.Status) != txtypes.FrameStatusSuccess { + continue + } + + frame.RolledBack = true + frame.StatusText = frameStatusText(frame.Status, true) + + if idx, ok := undoneBy[i]; ok { + frame.BatchFailedIndex = int(idx) + } + } +} + +// frameDurability asks txtypes which frames' effects survived, rebuilding the shapes it +// needs from what the page holds: a frame's mode and flags decide the validation prefix, +// the atomic batches and whether a POST_TX frame is present. +// +// Frames raised from a receipt alone carry no mode or flags, which reads as a transaction +// with no prefix and no batches - and the answer is then simply whether each frame +// succeeded, which is all that can be known without the transaction. +func frameDurability(frames []*models.TransactionPageDataFrame) []bool { + tx := &txtypes.FrameTx{Frames: make([]*txtypes.Frame, len(frames))} + extra := &txtypes.FrameReceiptExtra{Frames: make([]*txtypes.FrameReceipt, len(frames))} + + for i, frame := range frames { + // The batch bit is rebuilt from AtomicBatch rather than read out of Flags: the + // two are the same fact, and taking the meaning rather than the encoding keeps + // this right whichever of them a caller filled in. + flags := frame.Flags & txtypes.ApproveScopeMask + if frame.AtomicBatch { + flags |= txtypes.AtomicBatchFlag + } + + tx.Frames[i] = &txtypes.Frame{ + Mode: txtypes.FrameMode(frame.Mode), + Flags: flags, + } + extra.Frames[i] = &txtypes.FrameReceipt{Status: uint64(frame.Status)} + } + + return extra.DurableFrames(tx) +} + +// batchFailures maps each frame of a failed atomic batch to the frame whose failure undid +// it, so a rolled-back frame can name its cause. +func batchFailures(frames []*models.TransactionPageDataFrame) map[int]uint32 { + undoneBy := make(map[int]uint32, len(frames)) + batchStart := 0 + + for i, frame := range frames { + if frame.AtomicBatch && i+1 < len(frames) { + continue + } + + batch := frames[batchStart : i+1] + start := batchStart + batchStart = i + 1 + + for _, member := range batch { + if uint64(member.Status) != txtypes.FrameStatusFailed { + continue + } + + for offset := range batch { + undoneBy[start+offset] = member.Index + } + + break + } + } + + return undoneBy +} + +// frameSpecies classifies a frame for display. +// +// The species names come from the mempool's prefix-matching rules, which only decide +// anything within the validation prefix. A DEFAULT frame there deploys the sender's +// account code; the same frame after the prefix does not, and calling it a deployment +// would name it for a position it does not hold. After the prefix it is a settlement, +// which is what a DEFAULT frame is for once the operations have run. +func frameSpecies(frame *txtypes.Frame, sender common.Address, inValidationPrefix bool) txtypes.FrameSpecies { + species := frame.Species(sender) + if species == txtypes.SpeciesDeploy && !inValidationPrefix { + return txtypes.SpeciesPostOp + } + + return species +} + +// frameCaller is the account a frame's call comes from. +// +// Only a SENDER frame is entered by the sender. DEFAULT and VERIFY frames are entered by +// the ENTRY_POINT predeploy, which is what lets a paymaster or a verifier run without the +// sender calling it - and why the sender does not appear as the caller of most frames. +func frameCaller(frame *txtypes.Frame, sender common.Address) common.Address { + if frame.Mode == txtypes.FrameModeSender { + return sender + } + + // DEFAULT, VERIFY and POST_TX frames are all entered by the predeploy. + return txtypes.EntryPoint +} + +// executedFrames are the frames that ran: a skipped frame never did, and a frame the +// client reported no result for cannot be claimed to have. +func executedFrames(frames []*models.TransactionPageDataFrame) []*models.TransactionPageDataFrame { + executed := make([]*models.TransactionPageDataFrame, 0, len(frames)) + + for _, frame := range frames { + switch uint64(frame.Status) { + case txtypes.FrameStatusSuccess, txtypes.FrameStatusFailed: + executed = append(executed, frame) + } + } + + return executed +} + +// frameLogOwners maps each of a frame transaction's logs to the frame that emitted it. +// +// A frame transaction's logs are the per-frame lists concatenated in frame order, so the +// per-frame counts partition the flat list. A frame whose atomic batch rolled back had +// its logs discarded by the client, so it owns none of them and its count is already +// zero. +// +// The counts have to account for every log before any of them is claimed: a client that +// reported a partial set would otherwise shift every log after the gap onto the wrong +// frame, and a wrong attribution is worse than none. +func frameLogOwners(pageData *models.TransactionPageData, logCount int) []uint32 { + if !pageData.IsFrameTx || pageData.FrameResultsMissing || logCount == 0 { + return nil + } + + total := 0 + for _, frame := range pageData.Frames { + total += int(frame.LogCount) + } + + if total != logCount { + return nil + } + + owners := make([]uint32, 0, total) + + for _, frame := range pageData.Frames { + for i := 0; i < int(frame.LogCount); i++ { + owners = append(owners, frame.Index) + } + } + + return owners +} + +// attributeEventsToFrames marks each event with the frame that emitted it. +func attributeEventsToFrames(pageData *models.TransactionPageData) { + owners := frameLogOwners(pageData, len(pageData.Events)) + if owners == nil { + return + } + + for _, event := range pageData.Events { + if int(event.EventIndex) >= len(owners) { + continue + } + + event.FrameIndex = owners[event.EventIndex] + event.HasFrame = true + } +} + +// attributeTokenTransfersToFrames marks each token transfer with the frame that made it. +// +// A transfer is a decoded log, so it belongs to whichever frame emitted that log. The +// number of logs comes from the transaction row rather than from the transfers, which are +// only the subset of logs that decoded as one. +func attributeTokenTransfersToFrames(pageData *models.TransactionPageData) { + owners := frameLogOwners(pageData, int(pageData.EventCount)) + if owners == nil { + return + } + + for _, transfer := range pageData.TokenTransfers { + if int(transfer.EventIndex) >= len(owners) { + continue + } + + transfer.FrameIndex = owners[transfer.EventIndex] + transfer.HasFrame = true + } +} + +// attributeInternalTxsToFrames marks each traced call with the frame it was made from. +// +// A client that decomposes a frame transaction traces one top-level call per executed +// frame, in frame order, and the indexer stores the trace only once it has verified that +// shape. So each depth-0 call starts the next executed frame and everything below it +// belongs to that frame. +// +// Nothing specifies this - the callTracer is not part of execution-apis and EIP-8141 says +// nothing about debug tracing - so the shape is checked again here rather than assumed, +// and a trace that does not match is left unattributed. +func attributeInternalTxsToFrames(pageData *models.TransactionPageData) { + if !pageData.IsFrameTx || len(pageData.InternalTxs) == 0 { + return + } + + executed := executedFrames(pageData.Frames) + + roots := 0 + for _, itx := range pageData.InternalTxs { + if itx.Depth == 0 { + roots++ + } + } + + if roots == 0 || roots != len(executed) { + return + } + + current := -1 + + for _, itx := range pageData.InternalTxs { + if itx.Depth == 0 { + current++ + } + + if current < 0 { + continue + } + + itx.FrameIndex = executed[current].Index + itx.HasFrame = true + } +} + +// resolveFrameCalldata names what each frame calls. +// +// A frame carries its own calldata, so each one has its own method rather than the +// transaction having one - which is most of what makes a frame transaction readable: +// without it a frame is an address and a byte count. +func resolveFrameCalldata(ctx context.Context, pageData *models.TransactionPageData) { + sysContracts := services.GlobalBeaconService.GetSystemContractAddresses() + + // One lookup for the whole transaction rather than one per frame. + sigSet := make(map[types.TxSignatureBytes]struct{}, len(pageData.Frames)) + + for _, frame := range pageData.Frames { + if len(frame.Data) < 4 || !frame.HasTarget { + continue + } + + if skip, _ := utils.ShouldSkipSignatureLookup(frame.TargetAddr, false, sysContracts); skip { + continue + } + + var sig types.TxSignatureBytes + copy(sig[:], frame.Data[:4]) + sigSet[sig] = struct{}{} + } + + sigBytes := make([]types.TxSignatureBytes, 0, len(sigSet)) + for sig := range sigSet { + sigBytes = append(sigBytes, sig) + } + + var sigLookups map[types.TxSignatureBytes]*services.TxSignaturesLookup + if len(sigBytes) > 0 { + sigLookups = services.GlobalTxSignaturesService.LookupSignatures(ctx, sigBytes) + } + + for _, frame := range pageData.Frames { + if len(frame.Data) < 4 || !frame.HasTarget { + continue + } + + target := common.BytesToAddress(frame.TargetAddr) + + // The expiry verifier takes a raw deadline rather than a selector, and the page + // already shows the decoded time. + if target == txtypes.ExpiryVerifier { + frame.MethodName = "expiry check" + + continue + } + + if precompile := utils.GetPrecompileInfo(frame.TargetAddr); precompile != nil { + frame.MethodName = precompile.Name + frame.DecodedCalldata = utils.DecodePrecompileInput(precompile.Index, frame.Data) + + continue + } + + if name, ok := sysContracts[target]; ok && name != "Deposit Contract" { + frame.MethodName = name + + continue + } + + var sig types.TxSignatureBytes + copy(sig[:], frame.Data[:4]) + + lookup, found := sigLookups[sig] + if !found || lookup.Status != types.TxSigStatusFound { + continue + } + + frame.MethodName = lookup.Name + frame.MethodSignature = lookup.Signature + + if len(frame.Data) > 4 && lookup.Signature != "" { + frame.DecodedCalldata = utils.DecodeCalldata(lookup.Signature, frame.Data) + } + } +} + +// summarizeFrames totals what the frames did, which is where a frame transaction's gas +// goes and how much of it ran. +func summarizeFrames(pageData *models.TransactionPageData) { + pageData.FrameExecGasUsed = 0 + pageData.FrameStateGasUsed = 0 + pageData.FrameSuccessCount = 0 + pageData.FrameFailedCount = 0 + pageData.FrameSkippedCount = 0 + pageData.FrameRolledBackCnt = 0 + + for _, frame := range pageData.Frames { + pageData.FrameExecGasUsed += frame.ExecGasUsed + pageData.FrameStateGasUsed += frame.StateGasUsed + + switch uint64(frame.Status) { + case txtypes.FrameStatusSuccess: + pageData.FrameSuccessCount++ + + // Every member of a failed batch is marked rolled back, the one that failed + // and the ones that never ran included. Only a frame that succeeded had + // anything taken back from it. + if frame.RolledBack { + pageData.FrameRolledBackCnt++ + } + case txtypes.FrameStatusFailed: + if pageData.FrameFailedCount == 0 { + pageData.FrameFailedIndex = frame.Index + } + + pageData.FrameFailedCount++ + case txtypes.FrameStatusSkipped: + pageData.FrameSkippedCount++ + } + } + + applyFrameTxStatus(pageData) +} + +// applyFrameBodyReverted records whether an assertion frame took the whole execution body +// with it, which the frame statuses alone do not say: the frames it reverted still report +// the success they earned. +func applyFrameBodyReverted(pageData *models.TransactionPageData) { + for _, frame := range pageData.Frames { + if frame.Mode == uint8(txtypes.FrameModePostTx) && uint64(frame.Status) == txtypes.FrameStatusFailed { + pageData.FrameBodyReverted = true + + return + } + } +} + +// applyFrameTxStatus states the transaction's own outcome from its frames. +// +// A frame transaction that reached the chain ran and paid: its validation prefix +// succeeded, or the transaction would be invalid and never included. Frames within it can +// still fail, and that is not the transaction reverting - the fee was still owed, and what +// the frames outside the failure did stands. So a frame transaction is never "Failed" or +// "Reverted": it completed, and how completely is what the tooltip is for. It leads with +// how many frames failed, which is the one thing every view of the transaction says. +// +// The per-frame results live on the receipt, which may not have been kept. Without them +// the row's status still says whether any frame failed, and its revert reason - the frame +// failure summary the indexer stores - how many. +func applyFrameTxStatus(pageData *models.TransactionPageData) { + if !pageData.IsFrameTx { + return + } + + if pageData.FrameResultsMissing || len(pageData.Frames) == 0 { + applyFrameTxStatusFromRow(pageData) + + return + } + + pageData.Status = true + pageData.RevertReason = "" + + if pageData.FrameFailedCount == 0 { + pageData.StatusText = "Success" + pageData.FrameIncomplete = false + pageData.FrameStatusDetail = "Every frame of this transaction succeeded." + + return + } + + pageData.StatusText = "Complete" + pageData.FrameIncomplete = true + + failed := fmt.Sprintf("%d of %d frames failed", pageData.FrameFailedCount, len(pageData.Frames)) + if pageData.FrameFailedCount == 1 { + failed += fmt.Sprintf(" (frame #%d)", pageData.FrameFailedIndex) + } else { + failed += fmt.Sprintf(", the first being #%d", pageData.FrameFailedIndex) + } + + if pageData.FrameBodyReverted { + pageData.FrameStatusDetail = fmt.Sprintf( + "%s, the POST_TX frame among them (execution body reverted). A failed POST_TX frame reverts "+ + "everything the transaction did after its validation frames - not just its own atomic batch. "+ + "The transaction still reached the chain and its fee was still owed: only the validation "+ + "frames left anything behind.", + failed, + ) + + return + } + + parts := make([]string, 0, 2) + + if pageData.FrameRolledBackCnt == 1 { + parts = append(parts, "1 succeeded but was undone with its atomic batch") + } else if pageData.FrameRolledBackCnt > 1 { + parts = append(parts, fmt.Sprintf("%d succeeded but were undone with their atomic batch", pageData.FrameRolledBackCnt)) + } + + if pageData.FrameSkippedCount > 0 { + parts = append(parts, fmt.Sprintf("%d never ran", pageData.FrameSkippedCount)) + } + + detail := failed + if len(parts) > 0 { + detail += ", " + strings.Join(parts, ", ") + } + + pageData.FrameStatusDetail = fmt.Sprintf( + "%s. The transaction ran and paid its fee - a frame transaction only reaches the chain once its validation frames succeed. What the rest did stands.", + detail, + ) +} + +// applyFrameTxStatusFromRow states a frame transaction's outcome from its row alone, for +// when the per-frame results are not retained. The row's status says whether any frame +// failed; its revert reason, when the indexer stored one, says how many. +func applyFrameTxStatusFromRow(pageData *models.TransactionPageData) { + reverted := !pageData.Status + summary := pageData.RevertReason + + pageData.Status = true + pageData.RevertReason = "" + + if !reverted { + pageData.StatusText = "Success" + pageData.FrameIncomplete = false + pageData.FrameStatusDetail = "Every frame of this transaction succeeded." + + return + } + + if summary == "" { + summary = "Not every frame of this transaction succeeded" + } + + pageData.StatusText = "Complete" + pageData.FrameIncomplete = true + pageData.FrameStatusDetail = fmt.Sprintf( + "%s. The transaction ran and paid its fee - a frame transaction only reaches the chain once its validation frames succeed. Which frames failed is not known: their results are not retained for this block.", + summary, + ) +} + +// weiToEth converts a wei amount to ether. +func weiToEth(amount *big.Int) float64 { + if amount == nil { + return 0 + } + + value, _ := new(big.Float).Quo(new(big.Float).SetInt(amount), big.NewFloat(1e18)).Float64() + + return value +} + +// assignFrameBatches groups the frames into atomic batches so they can be shown together. +// +// A batch is a maximal run of frames in which every frame but the last carries the batch +// flag. Frames outside one each form a group of their own. +func assignFrameBatches(frames []*models.TransactionPageDataFrame) { + batchStart := 0 + batchIndex := 0 + + for i, frame := range frames { + if frame.AtomicBatch && i+1 < len(frames) { + continue + } + + size := i + 1 - batchStart + for _, member := range frames[batchStart : i+1] { + member.BatchIndex = batchIndex + member.BatchSize = size + } + + frames[batchStart].IsBatchStart = true + frame.IsBatchEnd = true + + batchStart = i + 1 + batchIndex++ + } +} + +// frameShapeLabel names a frame transaction by its validation prefix, which is the +// shortest run of leading frames whose success settles who pays. +// +// Only four prefixes propagate on the public mempool, and naming them is the single most +// useful thing to say about a frame transaction. Anything else is left unnamed rather +// than guessed at. +func frameShapeLabel(tx *txtypes.FrameTx) string { + prefixLen := tx.ValidationPrefixLength() + if prefixLen == 0 { + return "" + } + + species := make([]txtypes.FrameSpecies, 0, prefixLen) + + for i := 0; i < prefixLen; i++ { + found := tx.Frames[i].Species(tx.Sender) + + // An expiry check may lead any of the shapes and is not part of them. + if found == txtypes.SpeciesExpiryVerify { + continue + } + + species = append(species, found) + } + + deploys := false + sponsored := false + selfRelayed := false + + for _, s := range species { + switch s { + case txtypes.SpeciesDeploy: + deploys = true + case txtypes.SpeciesPay: + sponsored = true + case txtypes.SpeciesSelfVerify: + selfRelayed = true + } + } + + switch { + case deploys && sponsored: + return "Account deployment, sponsored" + case deploys: + return "Account deployment" + case sponsored: + return "Sponsored" + case selfRelayed: + return "Self-relayed" + default: + return "" + } +} + +// applyFrameTxEnvelope fills in what only the transaction envelope carries: whether its +// nonce is an account nonce, and the deadline of an expiry check. +// +// The frames' calldata is not stored relationally, so the deadline is only available +// while the block the transaction came in is still retained. +func applyFrameTxEnvelope(pageData *models.TransactionPageData, frameTx *txtypes.FrameTx) { + pageData.IsFrameTx = true + pageData.NonceIsAccount = frameTx.UsesLegacyNonce() + + buildFrameSignatures(pageData, frameTx) + buildFrameRecentRoots(pageData, frameTx) + + // Which of EIP-8141's extensions the payload used. A chain can run 8250 and 8272 + // independently, so this is a property of the transaction rather than of the chain. + pageData.FrameExtensions = frameTx.Extensions.String() + pageData.FrameHasKeyedNonces = frameTx.HasKeyedNonces() + + if !pageData.NonceIsAccount { + pageData.NonceKeys = make([]*models.TransactionPageDataNonceKey, 0, len(frameTx.NonceKeys)) + for _, key := range frameTx.NonceKeys { + if key == nil { + continue + } + + hex := key.Hex() + pageData.NonceKeys = append(pageData.NonceKeys, &models.TransactionPageDataNonceKey{ + Index: uint32(len(pageData.NonceKeys)), + Key: hex, + Short: shortNonceKey(hex), + }) + } + } + + for i, frame := range frameTx.Frames { + deadline, ok := frame.ExpiryDeadline() + if !ok { + continue + } + + pageData.HasExpiry = true + pageData.ExpiryTime = time.Unix(int64(deadline), 0) + + if i < len(pageData.Frames) { + pageData.Frames[i].HasExpiry = true + pageData.Frames[i].ExpiryTime = pageData.ExpiryTime + } + + break + } +} + +// frameSigSchemeNames name the signature schemes EIP-8141 validates. +var frameSigSchemeNames = map[uint8]string{ + uint8(txtypes.SigSchemeArbitrary): "Arbitrary", + uint8(txtypes.SigSchemeSecp256k1): "secp256k1", + uint8(txtypes.SigSchemeP256): "P256", +} + +// shortNonceKey abbreviates a nonce key for inline use. +// +// A key is a 256-bit identifier and applications are expected to derive it from something +// like a nullifier, so the usual case fills the full width. Sixteen of those are allowed +// in one transaction, which no line can hold; the full value stays available where the +// keys are listed. +func shortNonceKey(hex string) string { + const inlineLimit = 15 + + if len(hex) <= inlineLimit { + return hex + } + + return hex[:8] + "\u2026" + hex[len(hex)-4:] +} + +// buildFrameSignatures lists the authorisations the protocol checked before any frame ran. +// +// A frame transaction does not recover its sender from a signature - the sender is an +// explicit field - so the list is not a formality: it is where an account other than the +// sender agrees to be charged, which is the whole mechanism behind a sponsored +// transaction and is otherwise invisible on the page. +func buildFrameSignatures(pageData *models.TransactionPageData, frameTx *txtypes.FrameTx) { + if len(frameTx.Signatures) == 0 { + return + } + + signatures := make([]*models.TransactionPageDataSignature, 0, len(frameTx.Signatures)) + + for i, entry := range frameTx.Signatures { + if entry == nil { + continue + } + + scheme := uint8(entry.Scheme) + + sig := &models.TransactionPageDataSignature{ + Index: uint32(i), + Scheme: scheme, + SchemeName: frameSigSchemeNames[scheme], + Msg: entry.Msg, + Signature: entry.Signature, + } + + if sig.SchemeName == "" { + sig.SchemeName = fmt.Sprintf("scheme 0x%x", scheme) + } + + if gas, err := entry.VerificationGas(); err == nil { + sig.VerificationGas = gas + } + + sig.Parts = decodeFrameSignature(entry.Scheme, entry.Signature) + + if signer, ok := entry.ResolvedSigner(frameTx.Sender); ok { + sig.SignerAddr = signer.Bytes() + sig.HasSigner = true + sig.SignerIsSender = signer == frameTx.Sender + } + + signatures = append(signatures, sig) + } + + pageData.Signatures = signatures +} + +// buildTxSignature records the single ECDSA signature every type but a frame transaction +// is signed with. +// +// Unlike a frame transaction's list, this one is not an authorisation the protocol checks +// alongside a named sender - it is what the sender is derived from, so there is no sender +// to state until it has been verified. +func buildTxSignature(pageData *models.TransactionPageData, ethTx *txtypes.Transaction) { + v, r, sVal := ethTx.RawSignatureValues() + if v == nil || r == nil || sVal == nil { + return + } + + pad := func(n *big.Int) []byte { return common.LeftPadBytes(n.Bytes(), 32) } + + sig := &models.TransactionPageDataSignature{ + Scheme: uint8(txtypes.SigSchemeSecp256k1), + SchemeName: "secp256k1", + Role: "the sender, whose address is recovered from it", + Parts: []*models.TransactionPageDataSignaturePart{ + {Name: "v", Value: common.LeftPadBytes(v.Bytes(), 1), Note: "recovery id"}, + {Name: "r", Value: pad(r)}, + {Name: "s", Value: pad(sVal)}, + }, + } + + if len(pageData.FromAddr) > 0 { + sig.SignerAddr = pageData.FromAddr + sig.HasSigner = true + sig.SignerIsSender = true + } + + if gas, err := (&txtypes.FrameSignature{Scheme: txtypes.SigSchemeSecp256k1}).VerificationGas(); err == nil { + sig.VerificationGas = gas + } + + // The canonical encoding is r || s || v here, which is the order the raw bytes are + // shown in - and the opposite of a frame transaction's entries. + sig.Signature = append(append(pad(r), pad(sVal)...), common.LeftPadBytes(v.Bytes(), 1)...) + + pageData.Signatures = []*models.TransactionPageDataSignature{sig} + pageData.SignaturesRecoverSender = true +} + +// decodeFrameSignature splits an entry's raw bytes into the fields its scheme defines. +// +// EIP-8141 orders a secp256k1 entry as v || r || s, with v first - the opposite of +// go-ethereum's r || s || v - so the bytes cannot be read by eye without splitting them. +// Bytes that are not the length the scheme expects are left whole rather than carved up +// into fields that would be wrong. +func decodeFrameSignature(scheme txtypes.FrameSigScheme, sig []byte) []*models.TransactionPageDataSignaturePart { + part := func(name string, value []byte, note string) *models.TransactionPageDataSignaturePart { + return &models.TransactionPageDataSignaturePart{Name: name, Value: value, Note: note} + } + + switch scheme { + case txtypes.SigSchemeSecp256k1: + if len(sig) != 65 { + return nil + } + + return []*models.TransactionPageDataSignaturePart{ + part("v", sig[0:1], "recovery id, 0 or 1"), + part("r", sig[1:33], ""), + part("s", sig[33:65], ""), + } + + case txtypes.SigSchemeP256: + if len(sig) != 128 { + return nil + } + + return []*models.TransactionPageDataSignaturePart{ + part("r", sig[0:32], ""), + part("s", sig[32:64], ""), + part("qx", sig[64:96], ""), + part("qy", sig[96:128], ""), + } + + default: + // An arbitrary entry is witness data with no shape the protocol knows. + return nil + } +} + +// applySignatureRoles names what each entry authorises, as far as the transaction +// itself says. The sender's entry authorises the transaction; the payer's, when the +// receipt names one that is not the sender, is what let the fee be charged elsewhere. +// +// It runs after the receipt has been read, because the payer comes from there. +func applySignatureRoles(pageData *models.TransactionPageData) { + payer := common.BytesToAddress(pageData.PayerAddr) + sponsored := len(pageData.PayerAddr) > 0 && !pageData.PayerIsSender + + for _, sig := range pageData.Signatures { + if sig.Role != "" { + continue + } + + switch { + case !sig.HasSigner: + sig.Role = "witness for contract code, not checked by the protocol" + case sig.SignerIsSender: + sig.Role = "the sender, authorising the transaction" + case sponsored && common.BytesToAddress(sig.SignerAddr) == payer: + sig.Role = "the paymaster, agreeing to be charged" + default: + sig.Role = "a co-signer" + } + } +} + +// buildFrameRecentRoots lists the EIP-8272 roots the transaction declared, which is what +// lets a frame read them while it runs. +func buildFrameRecentRoots(pageData *models.TransactionPageData, frameTx *txtypes.FrameTx) { + if len(frameTx.RecentRoots) == 0 { + return + } + + roots := make([]*models.TransactionPageDataFrameRecentRoot, 0, len(frameTx.RecentRoots)) + + for i, ref := range frameTx.RecentRoots { + if ref == nil { + continue + } + + roots = append(roots, &models.TransactionPageDataFrameRecentRoot{ + Index: uint32(i), + SourceID: ref.SourceID.Bytes(), + Slot: ref.Slot, + Root: ref.Root.Bytes(), + }) + } + + pageData.FrameRecentRoots = roots +} + +// loadFrameReceiptFromBlockdb overlays what only a frame transaction's receipt reports: +// who settled the fee, and what each frame did. +// +// The transaction itself carries the frames' targets, values and gas budgets, so it is +// the source for those. None of this is held relationally - blockdb keeps a receipt at +// least as long as the transaction's own row survives, which makes a second copy of it +// in the database a duplicate of a longer-lived one. +func loadFrameReceiptFromBlockdb( + ctx context.Context, + pageData *models.TransactionPageData, + slot uint64, + blockRoot []byte, + txHash []byte, +) { + frameData := readFrameReceiptFromBlockdb(ctx, slot, blockRoot, txHash) + if frameData == nil { + return + } + + applyFramePayer(pageData, frameData) + applyFrameResults(pageData, frameData.Frames) +} + +// applyFramePayer records the payer, and whether it differs from the sender. +func applyFramePayer(pageData *models.TransactionPageData, frameData *bdbtypes.FrameReceiptData) { + payer := common.Address(frameData.Payer) + if payer == (common.Address{}) { + return + } + + pageData.PayerAddr = payer.Bytes() + pageData.PayerIsSender = common.BytesToAddress(pageData.FromAddr) == payer +} + +// readFrameReceiptFromBlockdb fetches the frame content of a receipt, or nil when the +// transaction has none or the object is gone. +func readFrameReceiptFromBlockdb( + ctx context.Context, + slot uint64, + blockRoot []byte, + txHash []byte, +) *bdbtypes.FrameReceiptData { + if blockdb.GlobalBlockDb == nil || !blockdb.GlobalBlockDb.SupportsExecData() { + return nil + } + + rctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + sections, err := blockdb.GlobalBlockDb.GetExecDataTxSections(rctx, slot, blockRoot, txHash, bdbtypes.ExecDataSectionReceiptMeta) + if err != nil || sections == nil || sections.ReceiptMetaData == nil { + return nil + } + + metaRaw, err := snappy.Decode(nil, sections.ReceiptMetaData) + if err != nil { + return nil + } + + _, frameData, err := bdbtypes.DecodeReceiptMetaSection(metaRaw) + if err != nil { + return nil + } + + return frameData +} diff --git a/handlers/transaction_test.go b/handlers/transaction_test.go new file mode 100644 index 000000000..d8d61723f --- /dev/null +++ b/handlers/transaction_test.go @@ -0,0 +1,948 @@ +package handlers + +import ( + "strings" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + bdbtypes "github.com/ethpandaops/dora/blockdb/types" + "github.com/ethpandaops/dora/types/models" + "github.com/ethpandaops/spamoor/txtypes" + "github.com/holiman/uint256" +) + +var ( + frameTestSender = common.HexToAddress("0x1111111111111111111111111111111111111111") + frameTestCallee = common.HexToAddress("0x2222222222222222222222222222222222222222") +) + +// frameTestTx is a two-frame transaction: a verifier that approves payment, then the +// call the sender meant to make. +func frameTestTx() *txtypes.FrameTx { + callee := frameTestCallee + + return &txtypes.FrameTx{ + Sender: frameTestSender, + Frames: []*txtypes.Frame{ + {Mode: txtypes.FrameModeVerify, Flags: txtypes.ApprovePayment, Value: uint256.NewInt(0)}, + {Mode: txtypes.FrameModeSender, Target: &callee, Value: uint256.NewInt(0)}, + }, + } +} + +// The transaction declares its frames and the receipt says what they did. A page built +// from the transaction alone has the first half and must not imply it has the second. +func TestFrameResultsAreMissingUntilAReceiptSuppliesThem(t *testing.T) { + pageData := &models.TransactionPageData{} + buildFramesFromEnvelope(pageData, frameTestTx()) + + if !pageData.FrameResultsMissing { + t.Fatal("frames built from the transaction alone have no results yet") + } + + for i, frame := range pageData.Frames { + if frame.Status != bdbtypes.FrameStatusUnknown { + t.Errorf("frame %d status = %d, want the unknown sentinel", i, frame.Status) + } + } + + applyFrameResults(pageData, []bdbtypes.FrameReceiptEntry{ + {Status: uint8(txtypes.FrameStatusSuccess), ExecGasUsed: 5000}, + {Status: uint8(txtypes.FrameStatusFailed), ExecGasUsed: 21000}, + }) + + if pageData.FrameResultsMissing { + t.Error("results were supplied, so the page must not still say they are missing") + } + + if pageData.Frames[0].StatusText != "Success" || pageData.Frames[1].StatusText != "Failed" { + t.Errorf("statuses = %q/%q, want Success/Failed", pageData.Frames[0].StatusText, pageData.Frames[1].StatusText) + } + + if pageData.Frames[1].ExecGasUsed != 21000 { + t.Errorf("frame 1 exec gas = %d, want 21000", pageData.Frames[1].ExecGasUsed) + } +} + +// Once the block a transaction came in is no longer retained, its frames cannot be read +// from it. The receipt reports one result per frame, so it still says how many there +// were and what each did - just not what any of them addressed. +func TestFrameResultsRaiseFramesWithoutTheTransaction(t *testing.T) { + pageData := &models.TransactionPageData{IsFrameTx: true} + + applyFrameResults(pageData, []bdbtypes.FrameReceiptEntry{ + {Status: uint8(txtypes.FrameStatusSuccess)}, + {Status: uint8(txtypes.FrameStatusSkipped)}, + {Status: uint8(txtypes.FrameStatusSuccess), LogCount: 3}, + }) + + if pageData.FrameCount != 3 || len(pageData.Frames) != 3 { + t.Fatalf("raised %d frames (count %d), want 3", len(pageData.Frames), pageData.FrameCount) + } + + for i, frame := range pageData.Frames { + if frame.Index != uint32(i) { + t.Errorf("frame %d carries index %d", i, frame.Index) + } + + if frame.HasTarget { + t.Errorf("frame %d claims a target the receipt does not report", i) + } + } + + if pageData.Frames[1].StatusText != "Skipped" { + t.Errorf("frame 1 = %q, want Skipped", pageData.Frames[1].StatusText) + } + + if pageData.Frames[2].LogCount != 3 { + t.Errorf("frame 2 log count = %d, want 3", pageData.Frames[2].LogCount) + } +} + +// A client that reports fewer results than the transaction has frames leaves the rest +// without one. Those frames must not keep a neighbour's result or read as failures. +func TestFramesBeyondTheReceiptKeepNoResult(t *testing.T) { + pageData := &models.TransactionPageData{} + buildFramesFromEnvelope(pageData, frameTestTx()) + + applyFrameResults(pageData, []bdbtypes.FrameReceiptEntry{ + {Status: uint8(txtypes.FrameStatusSuccess)}, + }) + + if pageData.Frames[1].Status != bdbtypes.FrameStatusUnknown { + t.Errorf("unreported frame status = %d, want the unknown sentinel", pageData.Frames[1].Status) + } + + if pageData.Frames[1].StatusText != "Unknown" { + t.Errorf("unreported frame reads as %q, want Unknown", pageData.Frames[1].StatusText) + } +} + +// A frame transaction's logs are the per-frame lists concatenated in frame order, so the +// per-frame counts say which frame emitted each one. +func TestEventsAreAttributedToTheFrameThatEmittedThem(t *testing.T) { + pageData := &models.TransactionPageData{ + IsFrameTx: true, + Frames: []*models.TransactionPageDataFrame{ + {Index: 0, Status: uint8(txtypes.FrameStatusSuccess), LogCount: 1}, + {Index: 1, Status: uint8(txtypes.FrameStatusSuccess), LogCount: 0}, + {Index: 2, Status: uint8(txtypes.FrameStatusSuccess), LogCount: 2}, + }, + Events: []*models.TransactionPageDataEvent{ + {EventIndex: 0}, {EventIndex: 1}, {EventIndex: 2}, + }, + } + + attributeEventsToFrames(pageData) + + want := []uint32{0, 2, 2} + for i, event := range pageData.Events { + if !event.HasFrame { + t.Fatalf("event %d was not attributed", i) + } + + if event.FrameIndex != want[i] { + t.Errorf("event %d attributed to frame %d, want %d", i, event.FrameIndex, want[i]) + } + } +} + +// A client that reports a partial set of per-frame counts would shift every log after the +// gap onto the wrong frame. No attribution beats a wrong one. +func TestEventsAreNotAttributedWhenTheCountsDisagree(t *testing.T) { + pageData := &models.TransactionPageData{ + IsFrameTx: true, + Frames: []*models.TransactionPageDataFrame{ + {Index: 0, Status: uint8(txtypes.FrameStatusSuccess), LogCount: 1}, + }, + Events: []*models.TransactionPageDataEvent{{EventIndex: 0}, {EventIndex: 1}}, + } + + attributeEventsToFrames(pageData) + + for i, event := range pageData.Events { + if event.HasFrame { + t.Errorf("event %d was attributed from counts that do not add up", i) + } + } +} + +// Without a receipt there are no per-frame counts, so nothing can be attributed. +func TestEventsAreNotAttributedWithoutResults(t *testing.T) { + pageData := &models.TransactionPageData{ + IsFrameTx: true, + FrameResultsMissing: true, + Frames: []*models.TransactionPageDataFrame{{Index: 0, LogCount: 1}}, + Events: []*models.TransactionPageDataEvent{{EventIndex: 0}}, + } + + attributeEventsToFrames(pageData) + + if pageData.Events[0].HasFrame { + t.Error("an event was attributed although no receipt reported the frames' logs") + } +} + +// A client that decomposes the transaction traces one root per executed frame, so each +// root and everything below it belongs to that frame. A skipped frame made no call and +// takes no root. +func TestInternalTxsAreAttributedToTheirFrames(t *testing.T) { + pageData := &models.TransactionPageData{ + IsFrameTx: true, + Frames: []*models.TransactionPageDataFrame{ + {Index: 0, Status: uint8(txtypes.FrameStatusSuccess)}, + {Index: 1, Status: uint8(txtypes.FrameStatusSkipped)}, + {Index: 2, Status: uint8(txtypes.FrameStatusSuccess)}, + }, + InternalTxs: []*models.TransactionPageDataInternalTx{ + {CallIndex: 0, Depth: 0}, + {CallIndex: 1, Depth: 1}, + {CallIndex: 2, Depth: 2}, + {CallIndex: 3, Depth: 0}, + }, + } + + attributeInternalTxsToFrames(pageData) + + want := []uint32{0, 0, 0, 2} + for i, itx := range pageData.InternalTxs { + if !itx.HasFrame { + t.Fatalf("call %d was not attributed", i) + } + + if itx.FrameIndex != want[i] { + t.Errorf("call %d attributed to frame %d, want %d", i, itx.FrameIndex, want[i]) + } + } +} + +// A trace whose roots are not the transaction's executed frames says nothing about them, +// and guessing a mapping onto it would name the wrong frame for every call. +func TestInternalTxsAreNotAttributedWhenTheTraceIsNotADecomposition(t *testing.T) { + pageData := &models.TransactionPageData{ + IsFrameTx: true, + Frames: []*models.TransactionPageDataFrame{ + {Index: 0, Status: uint8(txtypes.FrameStatusSuccess)}, + {Index: 1, Status: uint8(txtypes.FrameStatusSuccess)}, + }, + // One self-addressed placeholder for the whole transaction, as ethrex reports. + InternalTxs: []*models.TransactionPageDataInternalTx{{CallIndex: 0, Depth: 0}}, + } + + attributeInternalTxsToFrames(pageData) + + if pageData.InternalTxs[0].HasFrame { + t.Error("a placeholder root must not be read as a frame's calls") + } +} + +// A token transfer is a decoded log, so it belongs to whichever frame emitted that log. +// The transfers are only the subset of logs that decoded as one, so they are keyed on +// the flat event index rather than on their own position. +func TestTokenTransfersAreAttributedToTheirFrames(t *testing.T) { + pageData := &models.TransactionPageData{ + IsFrameTx: true, + EventCount: 4, + Frames: []*models.TransactionPageDataFrame{ + {Index: 0, Status: uint8(txtypes.FrameStatusSuccess), LogCount: 1}, + {Index: 1, Status: uint8(txtypes.FrameStatusSuccess), LogCount: 2}, + {Index: 2, Status: uint8(txtypes.FrameStatusSuccess), LogCount: 1}, + }, + // Only three of the four logs decoded as transfers. + TokenTransfers: []*models.TransactionPageDataTokenTransfer{ + {TransferIndex: 0, EventIndex: 0}, + {TransferIndex: 1, EventIndex: 2}, + {TransferIndex: 2, EventIndex: 3}, + }, + } + + attributeTokenTransfersToFrames(pageData) + + want := []uint32{0, 1, 2} + for i, transfer := range pageData.TokenTransfers { + if !transfer.HasFrame { + t.Fatalf("transfer %d was not attributed", i) + } + + if transfer.FrameIndex != want[i] { + t.Errorf("transfer %d attributed to frame %d, want %d", i, transfer.FrameIndex, want[i]) + } + } +} + +// One log can decode into several transfers - an ERC1155 batch is a single log - and +// they all belong to the frame that emitted it. +func TestTokenTransfersSharingALogShareItsFrame(t *testing.T) { + pageData := &models.TransactionPageData{ + IsFrameTx: true, + EventCount: 2, + Frames: []*models.TransactionPageDataFrame{ + {Index: 0, Status: uint8(txtypes.FrameStatusSuccess), LogCount: 1}, + {Index: 1, Status: uint8(txtypes.FrameStatusSuccess), LogCount: 1}, + }, + TokenTransfers: []*models.TransactionPageDataTokenTransfer{ + {TransferIndex: 0, EventIndex: 1}, + {TransferIndex: 1, EventIndex: 1}, + }, + } + + attributeTokenTransfersToFrames(pageData) + + for i, transfer := range pageData.TokenTransfers { + if transfer.FrameIndex != 1 { + t.Errorf("transfer %d attributed to frame %d, want 1", i, transfer.FrameIndex) + } + } +} + +// A frame transaction only reaches the chain once its validation frames succeed, so one +// that is on chain ran and paid. Frames within it failing is not the transaction +// reverting - what the other frames did stands. +func TestFrameTransactionWithAFailedFrameIsCompleteNotReverted(t *testing.T) { + pageData := &models.TransactionPageData{ + IsFrameTx: true, + // What the relational row said before the frames were read. + Status: false, + StatusText: "Failed", + RevertReason: "unknown", + Frames: []*models.TransactionPageDataFrame{ + {Index: 0, Status: uint8(txtypes.FrameStatusSuccess)}, + {Index: 1, Status: uint8(txtypes.FrameStatusSuccess), RolledBack: true}, + {Index: 2, Status: uint8(txtypes.FrameStatusFailed), RolledBack: true}, + {Index: 3, Status: uint8(txtypes.FrameStatusSkipped)}, + }, + } + + summarizeFrames(pageData) + + if !pageData.Status { + t.Error("a frame transaction on chain ran and paid, so it did not revert") + } + + if pageData.StatusText != "Complete" { + t.Errorf("status = %q, want Complete", pageData.StatusText) + } + + if !pageData.FrameIncomplete { + t.Error("frames failed, so the transaction did not do everything it asked for") + } + + if pageData.RevertReason != "" { + t.Errorf("revert reason = %q, want none: the transaction did not revert", pageData.RevertReason) + } + + for _, want := range []string{"1 of 4 frames failed (frame #2)", "1 succeeded but was undone", "1 never ran"} { + if !strings.Contains(pageData.FrameStatusDetail, want) { + t.Errorf("status detail %q does not mention %q", pageData.FrameStatusDetail, want) + } + } +} + +// With every frame succeeding there is nothing to qualify, and the transaction reads as +// an ordinary success. +func TestFrameTransactionWithNoFailuresIsASuccess(t *testing.T) { + pageData := &models.TransactionPageData{ + IsFrameTx: true, + Frames: []*models.TransactionPageDataFrame{ + {Index: 0, Status: uint8(txtypes.FrameStatusSuccess)}, + {Index: 1, Status: uint8(txtypes.FrameStatusSuccess)}, + }, + } + + summarizeFrames(pageData) + + if !pageData.Status || pageData.StatusText != "Success" { + t.Errorf("status = %v/%q, want true/Success", pageData.Status, pageData.StatusText) + } + + if pageData.FrameIncomplete { + t.Error("no frame failed, so nothing is incomplete") + } +} + +// The expiry frame checks the deadline when the transaction executes, so on an included +// transaction the deadline only says how much room it had left. +func TestExpiryIsMeasuredFromInclusion(t *testing.T) { + included := time.Date(2026, 8, 28, 12, 0, 0, 0, time.UTC) + + pageData := &models.TransactionPageData{ + HasExpiry: true, + BlockTime: included, + ExpiryTime: included.Add(30 * time.Minute), + } + + applyExpiryMargin(pageData) + + if pageData.ExpiryPassed { + t.Error("the deadline was ahead of the inclusion, so it had not passed") + } + + if pageData.ExpiryMargin != "30 min." { + t.Errorf("margin = %q, want 30 min.", pageData.ExpiryMargin) + } +} + +// A deadline already gone when the transaction was included is an anomaly: the frame +// should have rejected it. +func TestExpiryAlreadyPassedAtInclusion(t *testing.T) { + included := time.Date(2026, 8, 28, 12, 0, 0, 0, time.UTC) + + pageData := &models.TransactionPageData{ + HasExpiry: true, + BlockTime: included, + ExpiryTime: included.Add(-2 * time.Minute), + } + + applyExpiryMargin(pageData) + + if !pageData.ExpiryPassed { + t.Error("the deadline was behind the inclusion, so it had passed") + } + + if pageData.ExpiryMargin != "2 min." { + t.Errorf("margin = %q, want 2 min.", pageData.ExpiryMargin) + } +} + +// Without a block time there is nothing to measure against, and no margin is claimed. +func TestExpiryMarginNeedsAnInclusionTime(t *testing.T) { + pageData := &models.TransactionPageData{HasExpiry: true, ExpiryTime: time.Now()} + + applyExpiryMargin(pageData) + + if pageData.ExpiryMargin != "" { + t.Errorf("margin = %q, want none without an inclusion time", pageData.ExpiryMargin) + } +} + +// The signature list is where an account other than the sender agrees to be charged, so +// each entry is named for what it authorises rather than left as opaque bytes. +func TestFrameSignaturesAreNamedForWhatTheyAuthorise(t *testing.T) { + paymaster := common.HexToAddress("0x3333333333333333333333333333333333333333") + + frameTx := frameTestTx() + frameTx.Signatures = []*txtypes.FrameSignature{ + txtypes.SenderSignature(), + txtypes.SignerSignature(paymaster), + txtypes.ArbitrarySignature([]byte("witness")), + } + + pageData := &models.TransactionPageData{ + FromAddr: frameTestSender.Bytes(), + PayerAddr: paymaster.Bytes(), + } + + buildFrameSignatures(pageData, frameTx) + applySignatureRoles(pageData) + + if len(pageData.Signatures) != 3 { + t.Fatalf("built %d entries, want 3", len(pageData.Signatures)) + } + + sender, payer, witness := pageData.Signatures[0], pageData.Signatures[1], pageData.Signatures[2] + + // An entry naming no signer authorises for the sender. + if !sender.SignerIsSender || sender.Role != "the sender, authorising the transaction" { + t.Errorf("entry 0 = %+v, want the sender's", sender) + } + + if payer.Role != "the paymaster, agreeing to be charged" { + t.Errorf("entry 1 role = %q, want the paymaster's", payer.Role) + } + + // An arbitrary entry is witness data for contract code and has no protocol signer. + if witness.HasSigner { + t.Error("an arbitrary entry has no protocol-assigned signer") + } + + if witness.VerificationGas != 100 || sender.VerificationGas != 2800 { + t.Errorf("verification gas = %d/%d, want 100/2800", witness.VerificationGas, sender.VerificationGas) + } +} + +// A balance moves for reasons the numbers alone do not show, so the accounts that had a +// part in the transaction are named. +func TestStateChangeRolesNameTheAccountsThatHadAPart(t *testing.T) { + sender := common.HexToAddress("0x1111111111111111111111111111111111111111") + paymaster := common.HexToAddress("0x2222222222222222222222222222222222222222") + feeRecipient := common.HexToAddress("0x3333333333333333333333333333333333333333") + bystander := common.HexToAddress("0x4444444444444444444444444444444444444444") + + pageData := &models.TransactionPageData{ + FromAddr: sender.Bytes(), + PayerAddr: paymaster.Bytes(), + FeeRecipientAddr: feeRecipient.Bytes(), + StateChanges: []*models.TransactionPageDataStateChangeAccount{ + {Address: sender.Bytes()}, + {Address: paymaster.Bytes()}, + {Address: feeRecipient.Bytes()}, + {Address: bystander.Bytes()}, + }, + } + + annotateStateChangeRoles(pageData) + + for i, want := range []struct{ isSender, isPayer, isFee bool }{ + {isSender: true}, + {isPayer: true}, + {isFee: true}, + {}, + } { + got := pageData.StateChanges[i] + if got.IsSender != want.isSender || got.IsPayer != want.isPayer || got.IsFeeRecipient != want.isFee { + t.Errorf("account %d = sender:%v payer:%v fee:%v, want %v/%v/%v", + i, got.IsSender, got.IsPayer, got.IsFeeRecipient, want.isSender, want.isPayer, want.isFee) + } + } +} + +// A sender paying its own fee is the ordinary case and is not worth calling a paymaster. +func TestSenderPayingItsOwnFeeIsNotAPaymaster(t *testing.T) { + sender := common.HexToAddress("0x1111111111111111111111111111111111111111") + + pageData := &models.TransactionPageData{ + FromAddr: sender.Bytes(), + PayerAddr: sender.Bytes(), + PayerIsSender: true, + StateChanges: []*models.TransactionPageDataStateChangeAccount{ + {Address: sender.Bytes()}, + }, + } + + annotateStateChangeRoles(pageData) + + if pageData.StateChanges[0].IsPayer { + t.Error("the sender paying its own fee is not a paymaster") + } +} + +// EIP-8141 orders a secp256k1 entry v || r || s, with v first - the opposite of +// go-ethereum's r || s || v - so the split has to follow the spec rather than the habit. +func TestSecp256k1SignatureSplitsIntoVRS(t *testing.T) { + sig := make([]byte, 65) + sig[0] = 1 // v + sig[1], sig[32] = 0xaa, 0xbb // r, first and last byte + sig[33], sig[64] = 0xcc, 0xdd // s, first and last byte + + parts := decodeFrameSignature(txtypes.SigSchemeSecp256k1, sig) + if len(parts) != 3 { + t.Fatalf("split into %d parts, want 3", len(parts)) + } + + for i, want := range []struct { + name string + size int + first, last byte + }{ + {"v", 1, 1, 1}, + {"r", 32, 0xaa, 0xbb}, + {"s", 32, 0xcc, 0xdd}, + } { + got := parts[i] + if got.Name != want.name || len(got.Value) != want.size { + t.Errorf("part %d = %q of %d bytes, want %q of %d", i, got.Name, len(got.Value), want.name, want.size) + + continue + } + + if got.Value[0] != want.first || got.Value[len(got.Value)-1] != want.last { + t.Errorf("part %q spans the wrong bytes: %x…%x", got.Name, got.Value[0], got.Value[len(got.Value)-1]) + } + } +} + +// A P256 entry carries the public key alongside the signature, which is what the signer +// address is derived from. +func TestP256SignatureSplitsIntoRSAndPublicKey(t *testing.T) { + parts := decodeFrameSignature(txtypes.SigSchemeP256, make([]byte, 128)) + + names := make([]string, 0, len(parts)) + for _, part := range parts { + names = append(names, part.Name) + + if len(part.Value) != 32 { + t.Errorf("part %q is %d bytes, want 32", part.Name, len(part.Value)) + } + } + + if strings.Join(names, ",") != "r,s,qx,qy" { + t.Errorf("parts = %v, want r,s,qx,qy", names) + } +} + +// Bytes that are not the length the scheme expects are left whole: carving them up would +// name fields that are not there. +func TestSignatureOfTheWrongLengthIsNotSplit(t *testing.T) { + if parts := decodeFrameSignature(txtypes.SigSchemeSecp256k1, make([]byte, 64)); parts != nil { + t.Errorf("a 64-byte secp256k1 entry was split into %d parts", len(parts)) + } + + // An arbitrary entry is witness data with no shape the protocol knows. + if parts := decodeFrameSignature(txtypes.SigSchemeArbitrary, []byte("witness")); parts != nil { + t.Errorf("an arbitrary entry was split into %d parts", len(parts)) + } +} + +// A frame that fails on its own is a failure, not a batch that rolled back: there is no +// success to lose and nothing went down with it. Marking it rolled back made the page +// show it amber, with a tooltip naming the frame itself as the cause. +func TestALoneFailedFrameIsNotRolledBack(t *testing.T) { + frames := []*models.TransactionPageDataFrame{ + {Index: 0, Status: uint8(txtypes.FrameStatusSuccess)}, + {Index: 1, Status: uint8(txtypes.FrameStatusFailed), StatusText: "Failed"}, + {Index: 2, Status: uint8(txtypes.FrameStatusSuccess)}, + } + + markRolledBackFrames(frames) + + for i, frame := range frames { + if frame.RolledBack { + t.Errorf("frame %d was marked rolled back with no batch to roll back", i) + } + } +} + +// Inside a batch, only the frame that succeeded had anything taken back from it. The one +// that failed and the one that never ran are told by their own status. +func TestOnlyUndoneSuccessesAreMarkedRolledBack(t *testing.T) { + frames := []*models.TransactionPageDataFrame{ + {Index: 0, Status: uint8(txtypes.FrameStatusSuccess), AtomicBatch: true}, + {Index: 1, Status: uint8(txtypes.FrameStatusFailed), AtomicBatch: true}, + {Index: 2, Status: uint8(txtypes.FrameStatusSkipped)}, + } + + markRolledBackFrames(frames) + + if !frames[0].RolledBack || frames[0].BatchFailedIndex != 1 { + t.Errorf("the success before the failure should be undone by frame 1, got %v/%d", + frames[0].RolledBack, frames[0].BatchFailedIndex) + } + + if frames[1].RolledBack || frames[2].RolledBack { + t.Error("the failed and skipped frames say what happened to them themselves") + } +} + +// An assertion frame that fails reverts everything after the validation prefix, not just +// its own atomic batch. The frames it reverted still report the success they earned, so +// nothing but the mode says what happened. +func TestFailedAssertionFrameRevertsTheWholeBody(t *testing.T) { + pageData := &models.TransactionPageData{ + IsFrameTx: true, + Frames: []*models.TransactionPageDataFrame{ + // The validation prefix: a self-verify frame approves payment and ends it. + {Index: 0, Mode: uint8(txtypes.FrameModeVerify), Flags: txtypes.ApproveExecutionAndPayment, Status: uint8(txtypes.FrameStatusSuccess)}, + {Index: 1, Mode: uint8(txtypes.FrameModeSender), Status: uint8(txtypes.FrameStatusSuccess)}, + {Index: 2, Mode: uint8(txtypes.FrameModeSender), Status: uint8(txtypes.FrameStatusSuccess)}, + {Index: 3, Mode: uint8(txtypes.FrameModePostTx), Status: uint8(txtypes.FrameStatusFailed)}, + }, + } + + markRolledBackFrames(pageData.Frames) + applyFrameBodyReverted(pageData) + summarizeFrames(pageData) + + if !pageData.FrameBodyReverted { + t.Fatal("a failed assertion frame reverts the body") + } + + // The prefix commits even when the body reverts - that is what the payer paid for. + if pageData.Frames[0].RolledBack { + t.Error("a validation frame's changes are committed regardless of the body") + } + + for _, i := range []int{1, 2} { + if !pageData.Frames[i].RolledBack { + t.Errorf("frame %d reports success but the body was reverted under it", i) + } + } + + // The transaction still ran and paid, so it completed; what the tooltip has to say is + // that the one failed frame took the body with it. + if pageData.StatusText != "Complete" { + t.Errorf("status = %q, want Complete", pageData.StatusText) + } + + for _, want := range []string{"1 of 4 frames failed (frame #3)", "POST_TX frame among them (execution body reverted)"} { + if !strings.Contains(pageData.FrameStatusDetail, want) { + t.Errorf("status detail %q does not mention %q", pageData.FrameStatusDetail, want) + } + } +} + +// Without the receipt the frames' own results are unknown, but the row still says whether +// any failed and - through the summary the indexer stores as its reason - how many. That +// is stated as a completion, as it would be with the results, never as a revert. +func TestFrameTransactionWithoutResultsIsStatedFromItsRow(t *testing.T) { + pageData := &models.TransactionPageData{ + IsFrameTx: true, + Status: false, + StatusText: "Failed", + RevertReason: "2 of 5 frames failed", + Frames: []*models.TransactionPageDataFrame{ + {Index: 0}, {Index: 1}, {Index: 2}, {Index: 3}, {Index: 4}, + }, + FrameResultsMissing: true, + } + + applyFrameTxStatus(pageData) + + if !pageData.Status || pageData.StatusText != "Complete" || !pageData.FrameIncomplete { + t.Errorf("status = %v/%q/incomplete=%v, want Complete", pageData.Status, pageData.StatusText, pageData.FrameIncomplete) + } + + if pageData.RevertReason != "" { + t.Errorf("revert reason = %q, want none: the transaction did not revert", pageData.RevertReason) + } + + if !strings.HasPrefix(pageData.FrameStatusDetail, "2 of 5 frames failed") { + t.Errorf("status detail %q does not lead with the stored summary", pageData.FrameStatusDetail) + } + + // A row indexed before the summary was stored says only that some frame failed. + pageData.Status = false + pageData.RevertReason = "" + + applyFrameTxStatus(pageData) + + if pageData.StatusText != "Complete" || !strings.HasPrefix(pageData.FrameStatusDetail, "Not every frame") { + t.Errorf("status = %q, detail %q: want Complete with the unqualified summary", pageData.StatusText, pageData.FrameStatusDetail) + } + + // A row that reports success had no frame fail. + pageData.Status = true + + applyFrameTxStatus(pageData) + + if pageData.StatusText != "Success" || pageData.FrameIncomplete { + t.Errorf("status = %q/incomplete=%v, want Success", pageData.StatusText, pageData.FrameIncomplete) + } +} + +// Without an assertion frame the batch rule applies as before, and the body stands. +func TestBatchUnwindIsNotABodyRevert(t *testing.T) { + pageData := &models.TransactionPageData{ + IsFrameTx: true, + Frames: []*models.TransactionPageDataFrame{ + {Index: 0, Mode: uint8(txtypes.FrameModeVerify), Flags: txtypes.ApproveExecutionAndPayment, Status: uint8(txtypes.FrameStatusSuccess)}, + {Index: 1, Mode: uint8(txtypes.FrameModeSender), AtomicBatch: true, Status: uint8(txtypes.FrameStatusSuccess)}, + {Index: 2, Mode: uint8(txtypes.FrameModeSender), Status: uint8(txtypes.FrameStatusFailed)}, + {Index: 3, Mode: uint8(txtypes.FrameModeSender), Status: uint8(txtypes.FrameStatusSuccess)}, + }, + } + + markRolledBackFrames(pageData.Frames) + applyFrameBodyReverted(pageData) + summarizeFrames(pageData) + + if pageData.FrameBodyReverted { + t.Error("no assertion frame failed, so the body stands") + } + + if !pageData.Frames[1].RolledBack || pageData.Frames[1].BatchFailedIndex != 2 { + t.Errorf("the batched success should be undone by frame 2, got %v/%d", + pageData.Frames[1].RolledBack, pageData.Frames[1].BatchFailedIndex) + } + + // Outside the batch, and after it, this one survived. + if pageData.Frames[3].RolledBack { + t.Error("a frame outside the failed batch keeps its effects") + } + + if pageData.StatusText != "Complete" { + t.Errorf("status = %q, want Complete", pageData.StatusText) + } +} + +// envelopeTx is a frame transaction in a chosen envelope shape, carrying nothing but what +// the shape itself contributes. +func envelopeTx(extensions txtypes.FrameExtensions) *txtypes.FrameTx { + target := frameTestCallee + + return &txtypes.FrameTx{ + Extensions: extensions, + ChainID: uint256.NewInt(1), + Sender: frameTestSender, + NonceSeq: 4, + Frames: []*txtypes.Frame{{ + Mode: txtypes.FrameModeSender, + Target: &target, + Limits: txtypes.FrameLimits{Execution: 21000}, + Value: uint256.NewInt(0), + }}, + Fees: txtypes.FrameFees{ + GasTipCap: uint256.NewInt(1), + GasFeeCap: uint256.NewInt(2), + BlobFeeCap: uint256.NewInt(0), + }, + } +} + +// A chain can run EIP-8250 and EIP-8272 independently, so which extensions a payload used +// is a property of the transaction. The badge naming them is read off the envelope. +func TestEnvelopeShapeIsNamedFromTheTransaction(t *testing.T) { + for _, tc := range []struct { + extensions txtypes.FrameExtensions + want string + }{ + {0, "8141"}, + {txtypes.FrameExtKeyedNonces, "8141+8250"}, + {txtypes.FrameExtRecentRoots, "8141+8272"}, + {txtypes.FrameExtAll, "8141+8250+8272"}, + } { + pageData := &models.TransactionPageData{} + applyFrameTxEnvelope(pageData, envelopeTx(tc.extensions)) + + if pageData.FrameExtensions != tc.want { + t.Errorf("extensions = %q, want %q", pageData.FrameExtensions, tc.want) + } + + if got := pageData.FrameHasKeyedNonces; got != tc.extensions.Has(txtypes.FrameExtKeyedNonces) { + t.Errorf("%s: keyed nonces = %v", tc.want, got) + } + } +} + +// A transaction sequenced in a nonce domain of its own does not run against the sender's +// account nonce, so the keys it selects are what the sequence number means. +func TestKeyedNoncesNameTheDomainsTheySequenceIn(t *testing.T) { + frameTx := envelopeTx(txtypes.FrameExtKeyedNonces) + frameTx.NonceKeys = []*uint256.Int{uint256.NewInt(7), uint256.NewInt(9)} + + pageData := &models.TransactionPageData{} + applyFrameTxEnvelope(pageData, frameTx) + + if pageData.NonceIsAccount { + t.Error("a transaction selecting key 7 is not sequenced against the account nonce") + } + + if len(pageData.NonceKeys) != 2 || pageData.NonceKeys[0].Key != "0x7" || pageData.NonceKeys[1].Key != "0x9" { + t.Errorf("nonce keys = %v, want [0x7 0x9]", pageData.NonceKeys) + } +} + +// A key is a 256-bit identifier, and applications are meant to derive it from something +// like a nullifier, so the usual key fills the full width. Sixteen of those are allowed in +// one transaction, so the inline form is bounded while the full value stays available. +func TestFullWidthNonceKeysAreAbbreviatedInline(t *testing.T) { + full := uint256.MustFromHex("0x6f5d3ab1c2d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e") + + frameTx := envelopeTx(txtypes.FrameExtKeyedNonces) + frameTx.NonceKeys = []*uint256.Int{full} + + pageData := &models.TransactionPageData{} + applyFrameTxEnvelope(pageData, frameTx) + + key := pageData.NonceKeys[0] + if key.Key != full.Hex() { + t.Errorf("key = %s, want the full %s", key.Key, full.Hex()) + } + + if len(key.Short) > 15 { + t.Errorf("inline form %q is %d characters, want no more than 15", key.Short, len(key.Short)) + } + + if !strings.HasPrefix(key.Short, "0x6f5d3a") || !strings.HasSuffix(key.Short, "6d7e") { + t.Errorf("inline form = %q, want it to keep both ends of the key", key.Short) + } +} + +// A key short enough to read whole is not abbreviated, so nothing is hidden needlessly. +func TestShortNonceKeysAreNotAbbreviated(t *testing.T) { + frameTx := envelopeTx(txtypes.FrameExtKeyedNonces) + frameTx.NonceKeys = []*uint256.Int{uint256.NewInt(7)} + + pageData := &models.TransactionPageData{} + applyFrameTxEnvelope(pageData, frameTx) + + if got := pageData.NonceKeys[0].Short; got != "0x7" { + t.Errorf("inline form = %q, want the key itself", got) + } +} + +// Key zero is the sender's account nonce by definition, so a transaction selecting only +// that one is sequenced no differently from a transaction predating keyed nonces - and +// the page says nothing about domains for it. +func TestKeyZeroAloneIsTheAccountNonce(t *testing.T) { + frameTx := envelopeTx(txtypes.FrameExtKeyedNonces) + frameTx.NonceKeys = []*uint256.Int{uint256.NewInt(0)} + + pageData := &models.TransactionPageData{} + applyFrameTxEnvelope(pageData, frameTx) + + if !pageData.NonceIsAccount { + t.Error("key zero alone is the account nonce") + } + + if len(pageData.NonceKeys) != 0 { + t.Errorf("nonce keys = %v, want none to be named", pageData.NonceKeys) + } +} + +// A frame can only read a root the transaction declared up front, so the declarations are +// listed whether or not any frame went on to use them. +func TestRecentRootsAreListedAsDeclared(t *testing.T) { + frameTx := envelopeTx(txtypes.FrameExtRecentRoots) + frameTx.RecentRoots = []*txtypes.RecentRootReference{ + {SourceID: common.HexToHash("0xaa"), Slot: 1234, Root: common.HexToHash("0xbb")}, + {SourceID: common.HexToHash("0xcc"), Slot: 1235, Root: common.HexToHash("0xdd")}, + } + + pageData := &models.TransactionPageData{} + applyFrameTxEnvelope(pageData, frameTx) + + if len(pageData.FrameRecentRoots) != 2 { + t.Fatalf("roots = %d, want 2", len(pageData.FrameRecentRoots)) + } + + first := pageData.FrameRecentRoots[0] + if first.Index != 0 || first.Slot != 1234 { + t.Errorf("first root = index %d slot %d, want index 0 slot 1234", first.Index, first.Slot) + } + + if !strings.EqualFold(common.BytesToHash(first.Root).Hex(), common.HexToHash("0xbb").Hex()) { + t.Errorf("first root = %x, want ...bb", first.Root) + } + + if pageData.FrameRecentRoots[1].Index != 1 { + t.Errorf("second root index = %d, want 1", pageData.FrameRecentRoots[1].Index) + } +} + +// An envelope that declares no roots has no section to show. +func TestNoRecentRootsWithoutDeclarations(t *testing.T) { + pageData := &models.TransactionPageData{} + applyFrameTxEnvelope(pageData, envelopeTx(txtypes.FrameExtRecentRoots)) + + if pageData.FrameRecentRoots != nil { + t.Errorf("roots = %v, want none", pageData.FrameRecentRoots) + } +} + +// Storage on the protocol's own accounts is written by the transaction's validation +// rather than by any frame, so those accounts are named for what they are. +func TestProtocolAccountsAreNamedInStateChanges(t *testing.T) { + pageData := &models.TransactionPageData{ + FromAddr: frameTestSender.Bytes(), + StateChanges: []*models.TransactionPageDataStateChangeAccount{ + {Address: txtypes.NonceManager.Bytes()}, + {Address: txtypes.RecentRootAddress.Bytes()}, + {Address: frameTestCallee.Bytes()}, + }, + } + + annotateStateChangeRoles(pageData) + + if got := pageData.StateChanges[0].PredeployName; got != "NONCE_MANAGER" { + t.Errorf("keyed nonce storage = %q, want NONCE_MANAGER", got) + } + + if got := pageData.StateChanges[1].PredeployName; got != "RECENT_ROOTS" { + t.Errorf("recent root storage = %q, want RECENT_ROOTS", got) + } + + if got := pageData.StateChanges[2].PredeployName; got != "" { + t.Errorf("an ordinary account = %q, want no name", got) + } +} diff --git a/handlers/transactions.go b/handlers/transactions.go index 2d69ba63b..ffbe4d946 100644 --- a/handlers/transactions.go +++ b/handlers/transactions.go @@ -76,6 +76,7 @@ func parseTransactionsFilterForm(q url.Values) (*models.TransactionsFilter, stri form.Type2 = tb&0x4 != 0 form.Type3 = tb&0x8 != 0 form.Type4 = tb&0x10 != 0 + form.Type6 = tb&0x40 != 0 keep("type", tv) } } @@ -152,6 +153,9 @@ func resolveTransactionFilter(ctx context.Context, form *models.TransactionsFilt if form.Type4 { filter.TxTypes = append(filter.TxTypes, 4) } + if form.Type6 { + filter.TxTypes = append(filter.TxTypes, dbtypes.ElTxTypeFrame) + } return filter } @@ -379,6 +383,7 @@ func enrichElTransactionRows(ctx context.Context, dbTxs []*dbtypes.ElTransaction } } txData.IsCreate = tx.TxType&dbtypes.ElTxFlagCreate != 0 + txData.IsMultiTarget = dbtypes.IsMultiTarget(tx.TxType) if len(tx.MethodID) >= 4 { txData.MethodID = tx.MethodID[:4] diff --git a/indexer/beacon/client.go b/indexer/beacon/client.go index 2ba528705..653fe1025 100644 --- a/indexer/beacon/client.go +++ b/indexer/beacon/client.go @@ -22,6 +22,13 @@ import ( "github.com/sirupsen/logrus" ) +// A payload-available event can arrive a moment before the node serves the envelope it +// announces, so the load is retried across a window wide enough to cover that. +const ( + executionPayloadAnnounceRetries = 5 + executionPayloadAnnounceRetryDelay = 150 * time.Millisecond +) + // Client represents a consensus pool client that should be used for indexing beacon blocks. type Client struct { indexer *Indexer @@ -635,7 +642,7 @@ func (c *Client) processExecutionPayloadAvailableEvent(executionPayloadEvent *v1 } newPayload, err := block.EnsureExecutionPayload(func() (*all.SignedExecutionPayloadEnvelope, error) { - return LoadExecutionPayload(c.getContext(), c, executionPayloadEvent.BlockRoot) + return c.loadAnnouncedExecutionPayload(executionPayloadEvent.BlockRoot) }) if err != nil { return err @@ -649,9 +656,90 @@ func (c *Client) processExecutionPayloadAvailableEvent(executionPayloadEvent *v1 } } + c.backfillParentExecutionPayload(block) + return nil } +// loadAnnouncedExecutionPayload loads the payload envelope a payload-available event +// announced. +// +// The event asserts that the node has the payload, so a 404 immediately after it +// contradicts the event rather than answering it: some clients fire the event a moment +// before they serve the envelope. Taking that 404 at face value costs more than it looks +// like it should, because the announcement does not come a second time - the payload +// stays missing for the whole unfinalized range, and the block's transactions with it. +func (c *Client) loadAnnouncedExecutionPayload(root phase0.Root) (*all.SignedExecutionPayloadEnvelope, error) { + var lastErr error + + for retry := 0; retry < executionPayloadAnnounceRetries; retry++ { + if retry > 0 { + select { + case <-time.After(executionPayloadAnnounceRetryDelay): + case <-c.getContext().Done(): + return nil, c.getContext().Err() + } + } + + payload, err := LoadExecutionPayload(c.getContext(), c, root) + if payload != nil { + if retry > 0 { + c.logger.Debugf("execution payload for [0x%x] was announced %v before it was served", root, time.Duration(retry)*executionPayloadAnnounceRetryDelay) + } + + return payload, nil + } + + lastErr = err + } + + return nil, lastErr +} + +// backfillParentExecutionPayload loads the payload of the block that this block's payload +// builds on, when that one is still missing. +// +// A payload naming a parent hash is proof that the payload behind that hash was revealed +// and can be served. A block on top of it proves no such thing: post-EIP-7732 a block may +// extend a parent whose payload was never revealed. So this is where a payload that was +// announced before it could be served, and is therefore never announced again, is +// recovered with certainty rather than guessed at. +func (c *Client) backfillParentExecutionPayload(block *Block) { + payload := block.GetExecutionPayload(c.getContext()) + if payload == nil || payload.Message == nil || payload.Message.Payload == nil { + return + } + + parentHash := payload.Message.Payload.ParentHash + if bytes.Equal(parentHash[:], zeroHash[:]) { + return + } + + for _, parent := range c.indexer.blockCache.getBlocksByExecutionBlockHash(parentHash) { + if parent.HasExecutionPayload() { + continue + } + + newPayload, err := parent.EnsureExecutionPayload(func() (*all.SignedExecutionPayloadEnvelope, error) { + return LoadExecutionPayload(c.getContext(), c, parent.Root) + }) + if err != nil { + c.logger.Warnf("failed loading execution payload for %v [0x%x] named by its successor: %v", parent.Slot, parent.Root, err) + continue + } + + if !newPayload { + continue + } + + c.logger.Infof("recovered execution payload for %v [0x%x] named by its successor", parent.Slot, parent.Root) + + if err := c.persistExecutionPayload(parent); err != nil { + c.logger.Warnf("failed persisting recovered execution payload for %v [0x%x]: %v", parent.Slot, parent.Root, err) + } + } +} + func (c *Client) persistExecutionPayload(block *Block) error { payloadVer, payloadSSZ, err := MarshalVersionedSignedExecutionPayloadEnvelopeSSZ(block.dynSsz, block.executionPayload, c.indexer.blockCompression) if err != nil { diff --git a/indexer/execution/system_contracts/builder_deposit_indexer.go b/indexer/execution/system_contracts/builder_deposit_indexer.go index 9516d7376..72a87ae08 100644 --- a/indexer/execution/system_contracts/builder_deposit_indexer.go +++ b/indexer/execution/system_contracts/builder_deposit_indexer.go @@ -8,6 +8,7 @@ import ( "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/types" "github.com/ethpandaops/go-eth2-client/spec/phase0" + "github.com/ethpandaops/spamoor/txtypes" "github.com/jmoiron/sqlx" "github.com/sirupsen/logrus" @@ -118,7 +119,7 @@ func (bi *BuilderDepositIndexer) runBuilderDepositIndexerLoop() { } // processFinalTx parses a finalized builder deposit log into a request tx. -func (bi *BuilderDepositIndexer) processFinalTx(log *types.Log, tx *types.Transaction, header *types.Header, txFrom common.Address, dequeueBlock uint64, _ []*dbtypes.BuilderDepositTx) (*dbtypes.BuilderDepositTx, error) { +func (bi *BuilderDepositIndexer) processFinalTx(log *types.Log, tx *txtypes.Transaction, header *types.Header, txFrom common.Address, dequeueBlock uint64, _ []*dbtypes.BuilderDepositTx) (*dbtypes.BuilderDepositTx, error) { requestTx := bi.parseRequestLog(log) if requestTx == nil { return nil, fmt.Errorf("invalid builder deposit log") @@ -135,7 +136,7 @@ func (bi *BuilderDepositIndexer) processFinalTx(log *types.Log, tx *types.Transa } // processRecentTx parses a recent (unfinalized) builder deposit log into a request tx. -func (bi *BuilderDepositIndexer) processRecentTx(log *types.Log, tx *types.Transaction, header *types.Header, txFrom common.Address, dequeueBlock uint64, fork *execution.ForkWithClients, _ []*dbtypes.BuilderDepositTx) (*dbtypes.BuilderDepositTx, error) { +func (bi *BuilderDepositIndexer) processRecentTx(log *types.Log, tx *txtypes.Transaction, header *types.Header, txFrom common.Address, dequeueBlock uint64, fork *execution.ForkWithClients, _ []*dbtypes.BuilderDepositTx) (*dbtypes.BuilderDepositTx, error) { requestTx := bi.parseRequestLog(log) if requestTx == nil { return nil, fmt.Errorf("invalid builder deposit log") diff --git a/indexer/execution/system_contracts/builder_exit_indexer.go b/indexer/execution/system_contracts/builder_exit_indexer.go index 849455bd5..ad1e0c94d 100644 --- a/indexer/execution/system_contracts/builder_exit_indexer.go +++ b/indexer/execution/system_contracts/builder_exit_indexer.go @@ -7,6 +7,7 @@ import ( "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/types" "github.com/ethpandaops/go-eth2-client/spec/phase0" + "github.com/ethpandaops/spamoor/txtypes" "github.com/jmoiron/sqlx" "github.com/sirupsen/logrus" @@ -117,7 +118,7 @@ func (bi *BuilderExitIndexer) runBuilderExitIndexerLoop() { } // processFinalTx parses a finalized builder exit log into a request tx. -func (bi *BuilderExitIndexer) processFinalTx(log *types.Log, tx *types.Transaction, header *types.Header, txFrom common.Address, dequeueBlock uint64, _ []*dbtypes.BuilderExitTx) (*dbtypes.BuilderExitTx, error) { +func (bi *BuilderExitIndexer) processFinalTx(log *types.Log, tx *txtypes.Transaction, header *types.Header, txFrom common.Address, dequeueBlock uint64, _ []*dbtypes.BuilderExitTx) (*dbtypes.BuilderExitTx, error) { requestTx := bi.parseRequestLog(log) if requestTx == nil { return nil, fmt.Errorf("invalid builder exit log") @@ -134,7 +135,7 @@ func (bi *BuilderExitIndexer) processFinalTx(log *types.Log, tx *types.Transacti } // processRecentTx parses a recent (unfinalized) builder exit log into a request tx. -func (bi *BuilderExitIndexer) processRecentTx(log *types.Log, tx *types.Transaction, header *types.Header, txFrom common.Address, dequeueBlock uint64, fork *execution.ForkWithClients, _ []*dbtypes.BuilderExitTx) (*dbtypes.BuilderExitTx, error) { +func (bi *BuilderExitIndexer) processRecentTx(log *types.Log, tx *txtypes.Transaction, header *types.Header, txFrom common.Address, dequeueBlock uint64, fork *execution.ForkWithClients, _ []*dbtypes.BuilderExitTx) (*dbtypes.BuilderExitTx, error) { requestTx := bi.parseRequestLog(log) if requestTx == nil { return nil, fmt.Errorf("invalid builder exit log") diff --git a/indexer/execution/system_contracts/consolidation_indexer.go b/indexer/execution/system_contracts/consolidation_indexer.go index 0d5bf33fb..585272070 100644 --- a/indexer/execution/system_contracts/consolidation_indexer.go +++ b/indexer/execution/system_contracts/consolidation_indexer.go @@ -7,6 +7,7 @@ import ( "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/types" "github.com/ethpandaops/go-eth2-client/spec/phase0" + "github.com/ethpandaops/spamoor/txtypes" "github.com/jmoiron/sqlx" "github.com/sirupsen/logrus" @@ -111,7 +112,7 @@ func (ci *ConsolidationIndexer) runConsolidationIndexerLoop() { // processFinalTx is the callback for the contract indexer for finalized transactions // it parses the transaction and returns the corresponding consolidation request transaction -func (ci *ConsolidationIndexer) processFinalTx(log *types.Log, tx *types.Transaction, header *types.Header, txFrom common.Address, dequeueBlock uint64, _ []*dbtypes.ConsolidationRequestTx) (*dbtypes.ConsolidationRequestTx, error) { +func (ci *ConsolidationIndexer) processFinalTx(log *types.Log, tx *txtypes.Transaction, header *types.Header, txFrom common.Address, dequeueBlock uint64, _ []*dbtypes.ConsolidationRequestTx) (*dbtypes.ConsolidationRequestTx, error) { requestTx := ci.parseRequestLog(log) if requestTx == nil { return nil, fmt.Errorf("invalid consolidation log") @@ -129,7 +130,7 @@ func (ci *ConsolidationIndexer) processFinalTx(log *types.Log, tx *types.Transac // processRecentTx is the callback for the contract indexer for recent transactions // it parses the transaction and returns the corresponding consolidation request transaction -func (ci *ConsolidationIndexer) processRecentTx(log *types.Log, tx *types.Transaction, header *types.Header, txFrom common.Address, dequeueBlock uint64, fork *execution.ForkWithClients, _ []*dbtypes.ConsolidationRequestTx) (*dbtypes.ConsolidationRequestTx, error) { +func (ci *ConsolidationIndexer) processRecentTx(log *types.Log, tx *txtypes.Transaction, header *types.Header, txFrom common.Address, dequeueBlock uint64, fork *execution.ForkWithClients, _ []*dbtypes.ConsolidationRequestTx) (*dbtypes.ConsolidationRequestTx, error) { requestTx := ci.parseRequestLog(log) if requestTx == nil { return nil, fmt.Errorf("invalid consolidation log") diff --git a/indexer/execution/system_contracts/contract_indexer.go b/indexer/execution/system_contracts/contract_indexer.go index 51d79a8e3..bba1648a0 100644 --- a/indexer/execution/system_contracts/contract_indexer.go +++ b/indexer/execution/system_contracts/contract_indexer.go @@ -11,6 +11,7 @@ import ( "github.com/ethereum/go-ethereum" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/types" + "github.com/ethpandaops/spamoor/txtypes" "github.com/jmoiron/sqlx" "github.com/sirupsen/logrus" @@ -52,10 +53,10 @@ type contractIndexerOptions[TxType any] struct { persistRebaseRows func(tx *sqlx.Tx, rows []*dequeueRebaseRow) error // processFinalTx processes a finalized transaction log - processFinalTx func(log *types.Log, tx *types.Transaction, header *types.Header, txFrom common.Address, dequeueBlock uint64, parentTxs []*TxType) (*TxType, error) + processFinalTx func(log *types.Log, tx *txtypes.Transaction, header *types.Header, txFrom common.Address, dequeueBlock uint64, parentTxs []*TxType) (*TxType, error) // processRecentTx processes a recent (non-finalized) transaction log - processRecentTx func(log *types.Log, tx *types.Transaction, header *types.Header, txFrom common.Address, dequeueBlock uint64, fork *exectx.ForkWithClients, parentTxs []*TxType) (*TxType, error) + processRecentTx func(log *types.Log, tx *txtypes.Transaction, header *types.Header, txFrom common.Address, dequeueBlock uint64, fork *exectx.ForkWithClients, parentTxs []*TxType) (*TxType, error) // persistTxs persists processed transactions to the database persistTxs func(tx *sqlx.Tx, txs []*TxType) error @@ -361,21 +362,25 @@ func (ci *contractIndexer[_]) loadFilteredLogs(ctx context.Context, client *exec } // loadTransactionByHash fetches a transaction by its hash from the execution client -func (ci *contractIndexer[_]) loadTransactionByHash(ctx context.Context, client *execution.Client, hash common.Hash) (*types.Transaction, error) { +func (ci *contractIndexer[_]) loadTransactionByHash(ctx context.Context, client *execution.Client, hash common.Hash) (*txtypes.Transaction, error) { ctx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() - tx, _, err := client.GetRPCClient().GetEthClient().TransactionByHash(ctx, hash) + tx, _, err := client.GetRPCClient().GetTransactionByHash(ctx, hash) + return tx, err } -// txRecipient returns the transaction recipient. Contract-creation transactions -// have no recipient (To is nil); for those the request reached the system contract -// via an internal call, so the emitting contract address is the effective target. -func txRecipient(tx *types.Transaction, log *types.Log) common.Address { - if to := tx.To(); to != nil { +// txRecipient returns the transaction recipient. Contract-creation transactions have no +// recipient (To is nil), and a frame transaction has no recipient of its own - what it +// reports is one of several frame targets. In both cases the request reached the system +// contract through a call made from within the transaction, so the emitting contract +// address is the effective target. +func txRecipient(tx *txtypes.Transaction, log *types.Log) common.Address { + if to := tx.To(); to != nil && tx.Type() != txtypes.FrameTxType { return *to } + return log.Address } @@ -449,7 +454,7 @@ func (ci *contractIndexer[TxType]) processFinalizedBlocks(finalizedBlockNumber u // parse logs and load tx/block details var txHash, txHeaderHash []byte - var txDetails *types.Transaction + var txDetails *txtypes.Transaction var txBlockHeader *types.Header requestTxs := []*TxType{} @@ -480,11 +485,7 @@ func (ci *contractIndexer[TxType]) processFinalizedBlocks(finalizedBlockNumber u } // get transaction sender - chainId := txDetails.ChainId() - if chainId != nil && chainId.Cmp(big.NewInt(0)) == 0 { - chainId = nil - } - txFrom, err := types.Sender(types.LatestSignerForChainID(chainId), txDetails) + txFrom, err := txDetails.From(txDetails.ChainId()) if err != nil { return fmt.Errorf("could not decode tx sender (%v): %v", log.TxHash, err) } @@ -614,7 +615,7 @@ func (ci *contractIndexer[TxType]) processRecentBlocksForFork(headFork *exectx.F var logs []types.Log var reqError error var txHash, txHeaderHash []byte - var txDetails *types.Transaction + var txDetails *txtypes.Transaction var txBlockHeader *types.Header requestTxs := []*TxType{} @@ -687,11 +688,7 @@ func (ci *contractIndexer[TxType]) processRecentBlocksForFork(headFork *exectx.F } // get transaction sender - chainId := txDetails.ChainId() - if chainId != nil && chainId.Cmp(big.NewInt(0)) == 0 { - chainId = nil - } - txFrom, err := types.Sender(types.LatestSignerForChainID(chainId), txDetails) + txFrom, err := txDetails.From(txDetails.ChainId()) if err != nil { return fmt.Errorf("could not decode tx sender (%v): %v", log.TxHash, err) } diff --git a/indexer/execution/system_contracts/contract_indexer_test.go b/indexer/execution/system_contracts/contract_indexer_test.go index dc76ee531..f278c92b7 100644 --- a/indexer/execution/system_contracts/contract_indexer_test.go +++ b/indexer/execution/system_contracts/contract_indexer_test.go @@ -7,27 +7,49 @@ import ( "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/types" + "github.com/ethpandaops/spamoor/txtypes" + "github.com/holiman/uint256" ) -// TestTxRecipient checks the recipient lookup used by the contract-event -// callbacks. Contract-creation transactions have no recipient, so the lookup -// must fall back to the emitting contract instead of dereferencing a nil pointer. +// TestTxRecipient checks the recipient lookup used by the contract-event callbacks. +// Transactions that have no recipient of their own must fall back to the emitting +// contract instead of dereferencing a nil pointer or reporting a target that is not the +// transaction's. func TestTxRecipient(t *testing.T) { contract := common.HexToAddress("0x00000000219ab540356cBB839Cbe05303d7705Fa") log := &types.Log{Address: contract} // Contract-creation transaction (To is nil): falls back to the emitter. - createTx := types.NewTx(&types.DynamicFeeTx{Nonce: 0, To: nil, Value: big.NewInt(0)}) + createTx := txtypes.NewTx(&txtypes.DynamicFeeTx{Nonce: 0, To: nil, Value: big.NewInt(0)}) if got := txRecipient(createTx, log); got != contract { t.Errorf("creation tx recipient = %x, want %x", got, contract) } // Normal call transaction: uses its recipient. to := common.HexToAddress("0x00000000000000000000000000000000000000aa") - callTx := types.NewTx(&types.DynamicFeeTx{To: &to}) + callTx := txtypes.NewTx(&txtypes.DynamicFeeTx{To: &to}) if got := txRecipient(callTx, log); got != to { t.Errorf("call tx recipient = %x, want %x", got, to) } + + // Frame transaction: reports the first SENDER frame's target, which is one of + // several and not the transaction's own recipient, so the emitter wins. + frameTarget := common.HexToAddress("0x30592ef78d262bc79f0fe46355e07a51d685e382") + frameTx := txtypes.NewTx(&txtypes.FrameTx{ + Frames: []*txtypes.Frame{{ + Mode: txtypes.FrameModeSender, + Target: &frameTarget, + Value: uint256.NewInt(0), + }}, + }) + + if got := frameTx.To(); got == nil || *got != frameTarget { + t.Fatalf("frame tx To() = %v, want the first SENDER frame target", got) + } + + if got := txRecipient(frameTx, log); got != contract { + t.Errorf("frame tx recipient = %x, want %x", got, contract) + } } // TestApplyQueueDequeues checks the activation-aware queue drain: nothing dequeues while the diff --git a/indexer/execution/system_contracts/deposit_indexer.go b/indexer/execution/system_contracts/deposit_indexer.go index 09ffcbff7..5e8fa08a3 100644 --- a/indexer/execution/system_contracts/deposit_indexer.go +++ b/indexer/execution/system_contracts/deposit_indexer.go @@ -13,6 +13,7 @@ import ( "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/crypto" "github.com/ethpandaops/go-eth2-client/spec/phase0" + "github.com/ethpandaops/spamoor/txtypes" "github.com/jmoiron/sqlx" blsu "github.com/protolambda/bls12-381-util" zrnt_common "github.com/protolambda/zrnt/eth2/beacon/common" @@ -106,7 +107,7 @@ func (ds *DepositIndexer) runDepositIndexerLoop() { // processFinalTx is the callback for the contract indexer to process final transactions // it parses the transaction and returns the corresponding deposit transaction -func (ci *DepositIndexer) processFinalTx(log *types.Log, tx *types.Transaction, header *types.Header, txFrom common.Address, dequeueBlock uint64, parentTxs []*dbtypes.DepositTx) (*dbtypes.DepositTx, error) { +func (ci *DepositIndexer) processFinalTx(log *types.Log, tx *txtypes.Transaction, header *types.Header, txFrom common.Address, dequeueBlock uint64, parentTxs []*dbtypes.DepositTx) (*dbtypes.DepositTx, error) { requestTx := ci.parseDepositLog(log, parentTxs, 0) if requestTx == nil { return nil, fmt.Errorf("invalid deposit log") @@ -123,7 +124,7 @@ func (ci *DepositIndexer) processFinalTx(log *types.Log, tx *types.Transaction, // processRecentTx is the callback for the contract indexer to process recent transactions // it parses the transaction and returns the corresponding deposit transaction -func (ci *DepositIndexer) processRecentTx(log *types.Log, tx *types.Transaction, header *types.Header, txFrom common.Address, dequeueBlock uint64, fork *execution.ForkWithClients, parentTxs []*dbtypes.DepositTx) (*dbtypes.DepositTx, error) { +func (ci *DepositIndexer) processRecentTx(log *types.Log, tx *txtypes.Transaction, header *types.Header, txFrom common.Address, dequeueBlock uint64, fork *execution.ForkWithClients, parentTxs []*dbtypes.DepositTx) (*dbtypes.DepositTx, error) { forkId := uint64(fork.ForkId) clBlock := ci.indexerCtx.BeaconIndexer.GetBlocksByExecutionBlockHash(phase0.Hash32(log.BlockHash)) if len(clBlock) > 0 { diff --git a/indexer/execution/system_contracts/withdrawal_indexer.go b/indexer/execution/system_contracts/withdrawal_indexer.go index 48541f148..778394a4f 100644 --- a/indexer/execution/system_contracts/withdrawal_indexer.go +++ b/indexer/execution/system_contracts/withdrawal_indexer.go @@ -8,6 +8,7 @@ import ( "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/types" "github.com/ethpandaops/go-eth2-client/spec/phase0" + "github.com/ethpandaops/spamoor/txtypes" "github.com/jmoiron/sqlx" "github.com/sirupsen/logrus" @@ -112,7 +113,7 @@ func (wi *WithdrawalIndexer) runWithdrawalIndexerLoop() { // processFinalTx is the callback for the contract indexer to process final transactions // it parses the transaction and returns the corresponding withdrawal transaction -func (wi *WithdrawalIndexer) processFinalTx(log *types.Log, tx *types.Transaction, header *types.Header, txFrom common.Address, dequeueBlock uint64, _ []*dbtypes.WithdrawalRequestTx) (*dbtypes.WithdrawalRequestTx, error) { +func (wi *WithdrawalIndexer) processFinalTx(log *types.Log, tx *txtypes.Transaction, header *types.Header, txFrom common.Address, dequeueBlock uint64, _ []*dbtypes.WithdrawalRequestTx) (*dbtypes.WithdrawalRequestTx, error) { requestTx := wi.parseRequestLog(log) if requestTx == nil { return nil, fmt.Errorf("invalid withdrawal log") @@ -130,7 +131,7 @@ func (wi *WithdrawalIndexer) processFinalTx(log *types.Log, tx *types.Transactio // processRecentTx is the callback for the contract indexer to process recent transactions // it parses the transaction and returns the corresponding withdrawal transaction -func (wi *WithdrawalIndexer) processRecentTx(log *types.Log, tx *types.Transaction, header *types.Header, txFrom common.Address, dequeueBlock uint64, fork *execution.ForkWithClients, _ []*dbtypes.WithdrawalRequestTx) (*dbtypes.WithdrawalRequestTx, error) { +func (wi *WithdrawalIndexer) processRecentTx(log *types.Log, tx *txtypes.Transaction, header *types.Header, txFrom common.Address, dequeueBlock uint64, fork *execution.ForkWithClients, _ []*dbtypes.WithdrawalRequestTx) (*dbtypes.WithdrawalRequestTx, error) { requestTx := wi.parseRequestLog(log) if requestTx == nil { return nil, fmt.Errorf("invalid withdrawal log") diff --git a/indexer/execution/txindexer/loader.go b/indexer/execution/txindexer/loader.go index f45537b64..1d253739e 100644 --- a/indexer/execution/txindexer/loader.go +++ b/indexer/execution/txindexer/loader.go @@ -11,18 +11,19 @@ import ( "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" + "github.com/ethereum/go-ethereum/crypto" bdbtypes "github.com/ethpandaops/dora/blockdb/types" "github.com/ethpandaops/dora/clients/execution" exerpc "github.com/ethpandaops/dora/clients/execution/rpc" "github.com/ethpandaops/dora/indexer/beacon" "github.com/ethpandaops/dora/utils" + "github.com/ethpandaops/spamoor/txtypes" "github.com/sirupsen/logrus" ) // fetchBlockData fetches transactions and receipts for a block with retry logic. func (t *TxIndexer) fetchBlockData(ctx context.Context, ref *BlockRef) (*blockData, *execution.Client, error) { - var transactions []*types.Transaction + var transactions []*txtypes.Transaction var blockNumber uint64 var blockHash common.Hash @@ -65,6 +66,7 @@ func (t *TxIndexer) fetchBlockData(ctx context.Context, ref *BlockRef) (*blockDa "client": client.GetName(), "retry": retry + 1, }).Debug("failed to fetch transactions, retrying") + continue } @@ -149,8 +151,10 @@ func (t *TxIndexer) getClientsForBlock(ref *BlockRef) []*execution.Client { } // extractTransactionsFromBeaconBlock extracts transactions from a beacon block's execution payload. -// Returns nil if the block has no execution payload (pre-merge) or transactions cannot be extracted. -func (t *TxIndexer) extractTransactionsFromBeaconBlock(block *beacon.Block) ([]*types.Transaction, uint64, common.Hash) { +// Returns nil if the block has no execution payload (pre-merge) or if any transaction in the +// payload could not be decoded, in which case the caller fetches the block from an EL client +// instead. +func (t *TxIndexer) extractTransactionsFromBeaconBlock(block *beacon.Block) ([]*txtypes.Transaction, uint64, common.Hash) { beaconBlock := block.GetBlock(t.ctx) if beaconBlock == nil || beaconBlock.Message == nil || beaconBlock.Message.Body == nil { return nil, 0, common.Hash{} @@ -161,13 +165,22 @@ func (t *TxIndexer) extractTransactionsFromBeaconBlock(block *beacon.Block) ([]* return nil, 0, common.Hash{} } - transactions := make([]*types.Transaction, 0, len(payload.Transactions)) - for _, txBytes := range payload.Transactions { - tx := &types.Transaction{} - if err := tx.UnmarshalBinary(txBytes); err != nil { - t.logger.WithError(err).Debug("failed to unmarshal transaction from beacon block") - continue + transactions := make([]*txtypes.Transaction, 0, len(payload.Transactions)) + for idx, txBytes := range payload.Transactions { + tx, err := txtypes.DecodeTx(txBytes) + if err != nil { + // The payload carries a transaction this build cannot parse from its wire + // bytes. Keeping the rest would index the block short of transactions, so + // the payload is discarded in favour of asking an EL client, where a type + // with no decoder still yields the generic fields the node reports. + t.logger.WithError(err).WithFields(logrus.Fields{ + "blockNumber": payload.BlockNumber, + "txIndex": idx, + }).Debug("cannot decode transaction from beacon block, falling back to EL client") + + return nil, 0, common.Hash{} } + transactions = append(transactions, tx) } @@ -180,7 +193,7 @@ func (t *TxIndexer) fetchBlockTransactions( ctx context.Context, rpcClient *exerpc.ExecutionClient, blockHash []byte, -) ([]*types.Transaction, uint64, common.Hash, common.Address, []WithdrawalData, error) { +) ([]*txtypes.Transaction, uint64, common.Hash, common.Address, []WithdrawalData, error) { ethClient := rpcClient.GetEthClient() if ethClient == nil { return nil, 0, common.Hash{}, common.Address{}, nil, fmt.Errorf("ethclient not available") @@ -221,37 +234,28 @@ func (t *TxIndexer) fetchBlockTransactions( return nil, 0, common.Hash{}, common.Address{}, nil, fmt.Errorf("block number is nil") } - transactions := make([]*types.Transaction, 0, len(block.Transactions)) + transactions := make([]*txtypes.Transaction, 0, len(block.Transactions)) for idx, rawTx := range block.Transactions { - // Check transaction type for compatibility - var txHeader struct { - Type hexutil.Uint64 `json:"type"` - } - - isValid := false - if err := json.Unmarshal(rawTx, &txHeader); err == nil { - switch txHeader.Type { - case types.LegacyTxType, types.AccessListTxType, types.DynamicFeeTxType, - types.BlobTxType, types.SetCodeTxType: - isValid = true - } - } + tx, derived, err := decodeBlockTransaction(rawTx) + if err != nil { + t.logger.WithError(err).WithFields(logrus.Fields{ + "blockHash": hash.Hex(), + "txIndex": idx, + }).Debug("skipping transaction") - if !isValid { - t.logger.WithFields(logrus.Fields{ - "txIndex": idx, - "txType": txHeader.Type, - }).Debug("skipping unsupported transaction type") continue } - var tx types.Transaction - if err := json.Unmarshal(rawTx, &tx); err != nil { - t.logger.WithError(err).WithField("txIndex", idx).Debug("failed to unmarshal transaction") - continue + if derived != (common.Hash{}) { + t.logger.WithFields(logrus.Fields{ + "blockHash": hash.Hex(), + "txIndex": idx, + "reported": tx.Hash().Hex(), + "derived": derived.Hex(), + }).Warn("transaction does not re-encode to the hash reported for it, indexing it as reported") } - transactions = append(transactions, &tx) + transactions = append(transactions, tx) } withdrawals := make([]WithdrawalData, 0, len(block.Withdrawals)) @@ -267,22 +271,66 @@ func (t *TxIndexer) fetchBlockTransactions( return transactions, block.Number.ToInt().Uint64(), block.Hash, block.Coinbase, withdrawals, nil } +// decodeBlockTransaction rebuilds a transaction from one entry of an eth_getBlockBy* +// response. It returns the transaction, and the hash the decoded fields re-encode to when +// that disagrees with the hash the client reported. +// +// The transaction keeps the reported hash either way: it is the chain's identity for the +// transaction, and everything else - receipts, traces, the transaction the user follows a +// link to - is keyed by it. A disagreement means this build encodes the transaction +// differently from the client that produced it, which says nothing about which of the two +// is right, so it is reported and the transaction is indexed as reported rather than +// dropped. Fields the decoder derives rather than reads - the sender recovered from the +// signature, whether the transaction creates a contract - may be wrong in that case. +// +// A transaction of a type this build has no decoder for carries only the generic fields +// the node reported and cannot be re-encoded, so there is nothing to compare. +func decodeBlockTransaction(rawTx json.RawMessage) (*txtypes.Transaction, common.Hash, error) { + tx, err := txtypes.UnmarshalJSONTx(rawTx) + if err != nil { + return nil, common.Hash{}, fmt.Errorf("unmarshal transaction: %w", err) + } + + encoded, err := tx.MarshalBinary() + if err != nil { + return tx, common.Hash{}, nil + } + + if derived := crypto.Keccak256Hash(encoded); derived != tx.Hash() { + return tx, derived, nil + } + + return tx, common.Hash{}, nil +} + // fetchBlockReceipts fetches receipts for a block from an EL client. +// +// The response is decoded from raw JSON rather than through the typed ethclient so that +// type-specific receipt content survives: an EIP-8141 frame transaction reports its result +// per frame, and names the payer that actually settled it, neither of which a +// go-ethereum receipt can hold. func (t *TxIndexer) fetchBlockReceipts( ctx context.Context, rpcClient *exerpc.ExecutionClient, blockHash common.Hash, -) ([]*types.Receipt, error) { +) ([]*txtypes.Receipt, error) { ethClient := rpcClient.GetEthClient() if ethClient == nil { return nil, fmt.Errorf("ethclient not available") } - receipts, err := ethClient.BlockReceipts(ctx, rpc.BlockNumberOrHash{ - BlockHash: &blockHash, - }) - if err != nil { - return nil, fmt.Errorf("BlockReceipts failed: %w", err) + var raw json.RawMessage + if err := ethClient.Client().CallContext(ctx, &raw, "eth_getBlockReceipts", blockHash); err != nil { + return nil, fmt.Errorf("eth_getBlockReceipts failed: %w", err) + } + + if len(raw) == 0 || string(raw) == "null" { + return nil, fmt.Errorf("block receipts not found") + } + + receipts := []*txtypes.Receipt{} + if err := json.Unmarshal(raw, &receipts); err != nil { + return nil, fmt.Errorf("unmarshal block receipts: %w", err) } return receipts, nil @@ -323,7 +371,7 @@ func (t *TxIndexer) extractBeaconBlockData(block *beacon.Block) (common.Address, } // calculateTotalPriorityFees calculates the total priority fees paid in the block. -func (t *TxIndexer) calculateTotalPriorityFees(transactions []*types.Transaction, receipts []*types.Receipt) *big.Int { +func (t *TxIndexer) calculateTotalPriorityFees(transactions []*txtypes.Transaction, receipts []*txtypes.Receipt) *big.Int { if len(transactions) != len(receipts) { return big.NewInt(0) } diff --git a/indexer/execution/txindexer/loader_test.go b/indexer/execution/txindexer/loader_test.go new file mode 100644 index 000000000..633e696e5 --- /dev/null +++ b/indexer/execution/txindexer/loader_test.go @@ -0,0 +1,399 @@ +package txindexer + +import ( + "encoding/json" + "fmt" + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethpandaops/spamoor/txtypes" + "github.com/holiman/uint256" +) + +// Transaction 0 of glamsterdam-devnet-8 block 90220: a legacy contract creation, which +// every conforming client reports with a null "to". +const creationTxJSON = `{ + "type": "0x0", + "chainId": "0x1a6a8cc6e", + "nonce": "0x642", + "gasPrice": "0x4a817c800", + "gas": "0x7245c", + "to": %s, + "value": "0x0", + "input": "0x56fe1a11bde174dc4cc262afed6a46177a64ea999dc07be8dfd12f562d277c28296fcf2d5b1f7beb35cd480b53d070e0c655a57e7548919563d5f34e6f4d15b748b735b6c1eed9b1f67897290606283d930d285f", + "r": "0x3ff5600801ba387ca2a30ae64abe088bc89c71a85e4a94308c5b647cb0308f41", + "s": "0x1e45986beae6e6c0e381df35bf713c6e1adda09aada2578e716548f8300472de", + "v": "0x34d5198ff", + "hash": "0x7254debffd2dbfe70383428dd260fe1e43afbfa14abe9296dd6ebcdb8776f717" +}` + +const ( + creationTxHash = "0x7254debffd2dbfe70383428dd260fe1e43afbfa14abe9296dd6ebcdb8776f717" + creationTxSender = "0x49F047a23A510dD05b5CF2940fe20ef94D212329" +) + +// creationTx renders the sample transaction with the given "to" value, so the same +// transaction can be presented the way a conforming client reports it and the way a +// client that renders creations as the zero address reports it. +func creationTx(to string) json.RawMessage { + return json.RawMessage(fmt.Sprintf(creationTxJSON, to)) +} + +func TestDecodeBlockTransactionAcceptsContractCreation(t *testing.T) { + tx, derived, err := decodeBlockTransaction(creationTx("null")) + if err != nil { + t.Fatalf("decode failed: %v", err) + } + + if derived != (common.Hash{}) { + t.Errorf("re-encoded to %s, want agreement with the reported hash", derived.Hex()) + } + + if tx.To() != nil { + t.Errorf("expected a contract creation (nil to), got %s", tx.To().Hex()) + } + + if got := tx.Hash().Hex(); got != creationTxHash { + t.Errorf("hash = %s, want %s", got, creationTxHash) + } + + // The sender is only recoverable when the decoded transaction matches the signed one. + from, err := tx.From(tx.ChainId()) + if err != nil { + t.Fatalf("sender recovery failed: %v", err) + } + + if got := from.Hex(); got != creationTxSender { + t.Errorf("sender = %s, want %s", got, creationTxSender) + } +} + +// A client that renders a contract creation as a transaction to the zero address changes +// the transaction's RLP, and with it both its hash and the sender recovered from its +// signature. The decoder adopts the hash the client reported, so the disagreement is only +// visible once the decoded fields are re-encoded. The transaction is still indexed under +// the hash the chain knows it by; the re-encoded hash is reported so the client's defect +// is not silent. +func TestDecodeBlockTransactionReportsZeroAddressCreation(t *testing.T) { + tx, derived, err := decodeBlockTransaction(creationTx(`"0x0000000000000000000000000000000000000000"`)) + if err != nil { + t.Fatalf("decode failed: %v", err) + } + + if derived == (common.Hash{}) { + t.Fatal("expected the re-encoded hash to disagree with the reported one") + } + + if got := tx.Hash().Hex(); got != creationTxHash { + t.Errorf("hash = %s, want the reported %s", got, creationTxHash) + } +} + +// A transaction whose type has no decoder keeps the generic fields the node reported +// rather than being dropped. Dropping it would leave the block short of a transaction +// and shift every receipt index behind it. +func TestDecodeBlockTransactionKeepsUnsupportedType(t *testing.T) { + const unknownHash = "0x1111111111111111111111111111111111111111111111111111111111111111" + + tx, _, err := decodeBlockTransaction(json.RawMessage([]byte(`{ + "type": "0x7f", + "hash": "` + unknownHash + `", + "nonce": "0x642", + "gas": "0x7245c", + "to": "0x30592ef78d262bc79f0fe46355e07a51d685e382", + "value": "0x2a", + "input": "0x" + }`))) + if err != nil { + t.Fatalf("decode failed: %v", err) + } + + if tx.Type() != 0x7f { + t.Errorf("type = %d, want %d", tx.Type(), 0x7f) + } + + if got := tx.Hash().Hex(); got != unknownHash { + t.Errorf("hash = %s, want %s", got, unknownHash) + } + + if got := tx.Value().Uint64(); got != 42 { + t.Errorf("value = %d, want 42", got) + } +} + +// The hash is the chain's identity for a transaction and is what the indexer keys +// everything else by, so a response that omits it is rejected rather than indexed under a +// hash derived from fields that may not be what was signed. +func TestDecodeBlockTransactionRequiresReportedHash(t *testing.T) { + _, _, err := decodeBlockTransaction(json.RawMessage([]byte(`{ + "type": "0x0", "nonce": "0x642", "gasPrice": "0x4a817c800", "gas": "0x7245c", + "to": null, "value": "0x0", "input": "0x" + }`))) + if err == nil { + t.Fatal("expected an error for a transaction object with no hash") + } +} + +// frameTarget is the target of the sample frame transaction's SENDER frame. +var frameTarget = common.HexToAddress("0x30592ef78d262bc79f0fe46355e07a51d685e382") + +// sampleFrameTx builds a two-frame transaction of the shape spamoor's frametx scenario +// emits: an expiry check followed by the user's operation. +func sampleFrameTx() *txtypes.FrameTx { + expiry := common.HexToAddress("0x0000000000000000000000000000000000008141") + target := frameTarget + + return &txtypes.FrameTx{ + ChainID: uint256.NewInt(0x301824), + NonceKeys: []*uint256.Int{uint256.NewInt(0)}, + NonceSeq: 7, + Sender: common.HexToAddress("0x6df35438a4dfcdbd25c7a364ab77e3cfdce87fc5"), + Frames: []*txtypes.Frame{ + { + Mode: txtypes.FrameModeVerify, + Target: &expiry, + Limits: txtypes.FrameLimits{Execution: 5000}, + Value: uint256.NewInt(0), + Data: []byte{0, 0, 0, 0, 0x6a, 0x8f, 0x9c, 0xff}, + }, + { + Mode: txtypes.FrameModeSender, + Target: &target, + Limits: txtypes.FrameLimits{Execution: 30000}, + Value: uint256.NewInt(1), + Data: []byte{0xde, 0xad, 0xbe, 0xef}, + }, + }, + Signatures: []*txtypes.FrameSignature{ + {Scheme: txtypes.SigSchemeSecp256k1, Signature: make([]byte, 65)}, + }, + Fees: txtypes.FrameFees{ + GasTipCap: uint256.NewInt(0x77359400), + GasFeeCap: uint256.NewInt(0x4a817c800), + BlobFeeCap: uint256.NewInt(0), + }, + } +} + +// Frame transactions arrive as raw wire bytes in the beacon block's execution payload, +// which is the path that decodes every transaction the indexer sees. go-ethereum cannot +// represent type 0x06 at all, so before the switch to txtypes such a payload entry failed +// to decode and the transaction went unindexed. +func TestDecodeTxAcceptsFrameTransaction(t *testing.T) { + frameTx := sampleFrameTx() + + encoded, err := txtypes.NewTx(frameTx).MarshalBinary() + if err != nil { + t.Fatalf("encode failed: %v", err) + } + + tx, err := txtypes.DecodeTx(encoded) + if err != nil { + t.Fatalf("decode failed: %v", err) + } + + if tx.Type() != txtypes.FrameTxType { + t.Fatalf("type = %d, want %d", tx.Type(), txtypes.FrameTxType) + } + + decoded, ok := tx.Inner().(*txtypes.FrameTx) + if !ok { + t.Fatalf("inner type = %T, want *txtypes.FrameTx", tx.Inner()) + } + + if len(decoded.Frames) != 2 { + t.Fatalf("frames = %d, want 2", len(decoded.Frames)) + } + + // The sender is an explicit field rather than something recovered from a signature. + from, err := tx.From(tx.ChainId()) + if err != nil { + t.Fatalf("sender resolution failed: %v", err) + } + + if from != frameTx.Sender { + t.Errorf("sender = %s, want %s", from.Hex(), frameTx.Sender.Hex()) + } + + // A frame transaction has no single recipient, and dora must not read one into it. + if decoded.Frames[1].Target == nil || *decoded.Frames[1].Target != frameTarget { + t.Errorf("second frame target did not survive the round trip") + } +} + +// The EL client is the fallback whenever the beacon block payload cannot be decoded, and +// it reports transactions as JSON. A frame transaction has to survive that round trip +// too: the hash check re-encodes whatever the decoder produced, so a JSON representation +// that loses any part of the transaction is rejected rather than indexed. +func TestDecodeBlockTransactionAcceptsFrameTransactionJSON(t *testing.T) { + frameTx := sampleFrameTx() + + rawTx, err := txtypes.NewTx(frameTx).MarshalJSON() + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + + // A frame transaction addresses each frame separately and reports no recipient of + // its own, so the object must carry no top-level "to". + var fields map[string]any + if err := json.Unmarshal(rawTx, &fields); err != nil { + t.Fatalf("unmarshal into fields failed: %v", err) + } + + if _, ok := fields["to"]; ok { + t.Error(`frame transaction object must not carry a top-level "to"`) + } + + if _, ok := fields["frames"]; !ok { + t.Error("frame transaction object is missing its frames") + } + + tx, _, err := decodeBlockTransaction(rawTx) + if err != nil { + t.Fatalf("decode failed: %v", err) + } + + if tx.Hash() != txtypes.NewTx(frameTx).Hash() { + t.Errorf("hash = %s, want %s", tx.Hash().Hex(), txtypes.NewTx(frameTx).Hash().Hex()) + } + + decoded, ok := tx.Inner().(*txtypes.FrameTx) + if !ok { + t.Fatalf("inner type = %T, want *txtypes.FrameTx", tx.Inner()) + } + + if len(decoded.Frames) != len(frameTx.Frames) { + t.Errorf("frames = %d, want %d", len(decoded.Frames), len(frameTx.Frames)) + } +} + +// Receipts must be matched to transactions by hash. Matching them by position lets one +// unmatchable transaction consume the receipts belonging to the transactions after it. +func TestReceiptLookupIsByHashNotPosition(t *testing.T) { + mkTx := func(nonce uint64) *txtypes.Transaction { + return txtypes.NewTx(&txtypes.LegacyTx{ + Nonce: nonce, Gas: 21000, GasPrice: big.NewInt(1), Value: big.NewInt(0), + }) + } + + txs := []*txtypes.Transaction{mkTx(0), mkTx(1), mkTx(2)} + unmatchable := mkTx(99) + + // The block's receipts, with no receipt for the unmatchable transaction. + receipts := make([]*txtypes.Receipt, 0, len(txs)) + for i, tx := range txs { + receipts = append(receipts, &txtypes.Receipt{TxHash: tx.Hash(), TransactionIndex: uint(i)}) + } + + receiptMap := make(map[common.Hash]*txtypes.Receipt, len(receipts)) + for _, receipt := range receipts { + receiptMap[receipt.TxHash] = receipt + } + + if receiptMap[unmatchable.Hash()] != nil { + t.Fatal("unmatchable transaction must not resolve to a receipt") + } + + for i, tx := range txs { + receipt := receiptMap[tx.Hash()] + if receipt == nil { + t.Fatalf("transaction %d lost its receipt", i) + } + + if receipt.TransactionIndex != uint(i) { + t.Errorf("transaction %d matched receipt at index %d", i, receipt.TransactionIndex) + } + } +} + +// A frame's logs are reported inside the receipt that contains them, so they carry none +// of the position fields go-ethereum's Log type requires. Block receipts are decoded as +// one response, so a receipt that fails to decode fails every receipt beside it - one +// frame transaction that emitted a log cost its whole block an EL index. +func TestBlockReceiptsDecodeFrameLogsWithoutPosition(t *testing.T) { + // Shaped as ethrex reports it: the top-level list carries the full context, the + // per-frame copy carries only what the frame itself produced. + raw := []byte(`[ + { + "type": "0x2", + "status": "0x1", + "transactionHash": "0x1111111111111111111111111111111111111111111111111111111111111111", + "blockHash": "0x2222222222222222222222222222222222222222222222222222222222222222", + "blockNumber": "0x169", + "transactionIndex": "0x0", + "gasUsed": "0x5208", + "logs": [] + }, + { + "type": "0x6", + "status": "0x1", + "payer": "0x6df35438a4dfcdbd25c7a364ab77e3cfdce87fc5", + "transactionHash": "0x3333333333333333333333333333333333333333333333333333333333333333", + "blockHash": "0x2222222222222222222222222222222222222222222222222222222222222222", + "blockNumber": "0x169", + "transactionIndex": "0x8", + "gasUsed": "0x5261", + "logs": [ + { + "address": "0xffffffffffffffffffffffffffffffffffffffff", + "topics": ["0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"], + "data": "0x", + "blockHash": "0x2222222222222222222222222222222222222222222222222222222222222222", + "blockNumber": "0x169", + "transactionHash": "0x3333333333333333333333333333333333333333333333333333333333333333", + "transactionIndex": "0x8", + "logIndex": "0x4", + "removed": false + } + ], + "frameReceipts": [ + {"status": "0x1", "gasUsed": "0x0", "stateGasUsed": "0x0", "logs": []}, + {"status": "0x1", "gasUsed": "0x0", "stateGasUsed": "0x0", "logs": [ + { + "address": "0xffffffffffffffffffffffffffffffffffffffff", + "topics": ["0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"], + "data": "0x" + } + ]} + ] + } + ]`) + + receipts := []*txtypes.Receipt{} + if err := json.Unmarshal(raw, &receipts); err != nil { + t.Fatalf("block receipts must decode when a frame's logs omit their position: %v", err) + } + + if len(receipts) != 2 { + t.Fatalf("receipts = %d, want 2 - a failed receipt takes the whole block with it", len(receipts)) + } + + extra := receipts[1].FrameExtra() + if extra == nil { + t.Fatal("frame receipt content was lost") + } + + if len(extra.Frames) != 2 { + t.Fatalf("frames = %d, want 2", len(extra.Frames)) + } + + // The per-frame log counts are what attribute the transaction's flat log list back + // to the frames that emitted it. + if got := len(extra.Frames[0].Logs); got != 0 { + t.Errorf("frame 0 logs = %d, want 0", got) + } + + if got := len(extra.Frames[1].Logs); got != 1 { + t.Fatalf("frame 1 logs = %d, want 1", got) + } + + // A nested log inherits the transaction it belongs to from the receipt around it. + if got := extra.Frames[1].Logs[0].TxHash; got != receipts[1].TxHash { + t.Errorf("nested log tx hash = %s, want the receipt's %s", got.Hex(), receipts[1].TxHash.Hex()) + } + + if got := extra.Frames[1].Logs[0].BlockNumber; got != 0x169 { + t.Errorf("nested log block number = %d, want 361", got) + } +} diff --git a/indexer/execution/txindexer/process_blocks.go b/indexer/execution/txindexer/process_blocks.go index b57e1c1b9..9e64158b5 100644 --- a/indexer/execution/txindexer/process_blocks.go +++ b/indexer/execution/txindexer/process_blocks.go @@ -7,7 +7,7 @@ import ( "time" "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/core/types" + "github.com/ethpandaops/spamoor/txtypes" "github.com/jmoiron/sqlx" "github.com/sirupsen/logrus" @@ -29,8 +29,8 @@ const ( type blockData struct { BlockNumber uint64 BlockHash common.Hash - Transactions []*types.Transaction - Receipts []*types.Receipt + Transactions []*txtypes.Transaction + Receipts []*txtypes.Receipt FeeRecipient common.Address // Fee recipient from beacon block Withdrawals []WithdrawalData // Withdrawals from beacon block TotalPriorityFees *big.Int // Total priority fees in the block @@ -128,12 +128,12 @@ func (t *TxIndexer) processElBlock(ref *BlockRef) (*blockStats, error) { } } - // Build trace lookup map (txHash → call trace) for O(1) access per tx - traceMap := make(map[common.Hash]*exerpc.CallTraceCall, len(data.TraceResults)) + // Build trace lookup map (txHash → top-level call frames) for O(1) access per tx + traceMap := make(map[common.Hash][]*exerpc.CallTraceCall, len(data.TraceResults)) for i := range data.TraceResults { tr := &data.TraceResults[i] - if tr.Result != nil { - traceMap[tr.TxHash] = tr.Result + if len(tr.Roots) > 0 { + traceMap[tr.TxHash] = tr.Roots } } @@ -155,20 +155,33 @@ func (t *TxIndexer) processElBlock(ref *BlockRef) (*blockStats, error) { // Phase 2: Process transactions (pure computation, no I/O) procCtx := newTxProcessingContext(t.ctx, client, t, ref, data) - receiptIdx := 0 - dbCommitCallbacks := make([]dbCommitCallback, 0, len(data.Transactions)) - for _, tx := range data.Transactions { - var receipt *types.Receipt - for receiptIdx < len(data.Receipts) { - receipt = data.Receipts[receiptIdx] + // Receipts are matched by transaction hash rather than by position: the transaction + // list can be sparse when a transaction fails to decode, and a receipt carries the + // authoritative transaction index that the tx UID is built from. + receiptMap := make(map[common.Hash]*txtypes.Receipt, len(data.Receipts)) + for _, receipt := range data.Receipts { + receiptMap[receipt.TxHash] = receipt + } - if receipt.TxHash == tx.Hash() { - break - } - receiptIdx++ - } + unmatchedTxs := 0 + dbCommitCallbacks := make([]dbCommitCallback, 0, len(data.Transactions)) + for idx, tx := range data.Transactions { + // A transaction is only indexed together with its own receipt. Pairing it with any + // other receipt would file it under a foreign transaction index and attach foreign + // gas, status and event data to it. + receipt := receiptMap[tx.Hash()] if receipt == nil { - break + unmatchedTxs++ + + t.logger.WithFields(logrus.Fields{ + "blockNumber": data.BlockNumber, + "blockHash": data.BlockHash.Hex(), + "txIndex": idx, + "txHash": tx.Hash().Hex(), + "client": client.GetName(), + }).Warn("no receipt matches transaction, skipping transaction") + + continue } // Look up trace for this transaction (may be nil) @@ -184,6 +197,20 @@ func (t *TxIndexer) processElBlock(ref *BlockRef) (*blockStats, error) { } } + // An incomplete block is committed rather than dropped, so the indexer keeps making + // progress, but it must be visible: the block is stored short of transactions and + // nothing revisits it once it carries a status. + if unmatchedTxs > 0 || len(data.Transactions) != len(data.Receipts) { + t.logger.WithFields(logrus.Fields{ + "blockNumber": data.BlockNumber, + "blockHash": data.BlockHash.Hex(), + "transactions": len(data.Transactions), + "receipts": len(data.Receipts), + "unmatched": unmatchedTxs, + "client": client.GetName(), + }).Warn("block indexed with incomplete transaction set") + } + // Process fee recipient and withdrawals if beacon block data is available // (this must happen before batch resolution so these accounts are included) if err := t.processBlockRewards(procCtx, data); err != nil { diff --git a/indexer/execution/txindexer/process_frames.go b/indexer/execution/txindexer/process_frames.go new file mode 100644 index 000000000..1c1729db7 --- /dev/null +++ b/indexer/execution/txindexer/process_frames.go @@ -0,0 +1,359 @@ +package txindexer + +import ( + "fmt" + "math/big" + + "github.com/ethereum/go-ethereum/common" + bdbtypes "github.com/ethpandaops/dora/blockdb/types" + exerpc "github.com/ethpandaops/dora/clients/execution/rpc" + "github.com/ethpandaops/spamoor/txtypes" + "github.com/sirupsen/logrus" +) + +// pendingFrame is one frame of an EIP-8141 frame transaction, paired with the result the +// receipt reports for it. +// +// A frame transaction is an ordered list of calls rather than a single one, so the fields +// el_transactions holds for an ordinary transaction - a recipient, a value, a gas limit, +// a status - exist once per frame here. +type pendingFrame struct { + index uint16 + mode uint8 + flags uint8 + + // target is the frame's resolved recipient: frames declare no target to address the + // transaction's sender. + target common.Address + toAccount *pendingAccount + + value *big.Int + dataLen uint32 + methodID []byte + + execGasLimit uint64 + stateGasLimit uint64 + + // hasResult reports whether the receipt carried a result for this frame. A client + // that reports fewer results than there are frames leaves the rest without one, + // rather than borrowing another frame's. + hasResult bool + status uint64 + execGasUsed uint64 + stateGasUsed uint64 + logCount uint32 + + // rolledBack marks a frame whose atomic batch was undone. Such a frame may still + // report success, but nothing it did survived. + rolledBack bool +} + +// storedStatus is the frame's result as it is recorded. +// +// A client that reported no result for the frame leaves it neither succeeded nor failed, +// which the sentinel says and a zero status would not - zero is how a failure is spelled. +func (f *pendingFrame) storedStatus() uint8 { + if !f.hasResult { + return bdbtypes.FrameStatusUnknown + } + + return uint8(f.status) +} + +// frameReceiptData renders the frames as the receipt content stored in blockdb, or nil +// for a transaction that has none. It is what keeps a frame transaction legible after its +// relational rows have been pruned. +func frameReceiptData(frames []*pendingFrame, payer common.Address) *bdbtypes.FrameReceiptData { + if len(frames) == 0 { + return nil + } + + data := &bdbtypes.FrameReceiptData{ + Payer: payer, + Frames: make([]bdbtypes.FrameReceiptEntry, 0, len(frames)), + } + + for _, frame := range frames { + data.Frames = append(data.Frames, bdbtypes.FrameReceiptEntry{ + Status: frame.storedStatus(), + ExecGasUsed: frame.execGasUsed, + StateGasUsed: frame.stateGasUsed, + LogCount: frame.logCount, + }) + } + + return data +} + +// executed reports whether the frame ran at all. A skipped frame never did, and a frame +// with no reported result cannot be claimed to have. +func (f *pendingFrame) executed() bool { + return f.hasResult && f.status != txtypes.FrameStatusSkipped +} + +// succeeded reports whether the frame's effects are durable: it ran, it reported success, +// and its atomic batch was not rolled back afterwards. +func (f *pendingFrame) succeeded() bool { + return f.hasResult && f.status == txtypes.FrameStatusSuccess && !f.rolledBack +} + +// frameFailureSummary states how many of a transaction's frames failed, as "k of n frames +// failed", or nothing when none did. A failed POST_TX frame is called out, since it +// reverts the whole execution body rather than one atomic batch. The summary is stored as +// the transaction's revert reason: the frames' own results live only in blockdb, and this +// is what every listing of the transaction has to say about them. +func frameFailureSummary(frames []*pendingFrame) string { + failed := 0 + postTxFailed := false + + for _, frame := range frames { + if !frame.hasResult || frame.status != txtypes.FrameStatusFailed { + continue + } + + failed++ + + if frame.mode == uint8(txtypes.FrameModePostTx) { + postTxFailed = true + } + } + + if failed == 0 { + return "" + } + + summary := fmt.Sprintf("%d of %d frames failed", failed, len(frames)) + if postTxFailed { + summary += ", the POST_TX frame among them (execution body reverted)" + } + + return summary +} + +// resolveFrames builds the per-frame view of a frame transaction, pairing each frame with +// the receipt's result for it and ensuring an account for its target. +func (ctx *txProcessingContext) resolveFrames( + frameTx *txtypes.FrameTx, + receipt *txtypes.Receipt, + fromAccount *pendingAccount, +) []*pendingFrame { + extra := receipt.FrameExtra() + + // EIP-8141 caps a transaction at MaxFrames, and a block carrying more than that is + // not valid. The stored frame list is bounded by the same cap, so trusting the count + // anyway would fail to encode. + txFrames := frameTx.Frames + if len(txFrames) > txtypes.MaxFrames { + ctx.indexer.logger.WithFields(logrus.Fields{ + "frames": len(txFrames), + "cap": txtypes.MaxFrames, + }).Warn("transaction reports more frames than the protocol allows, indexing the first ones only") + + txFrames = txFrames[:txtypes.MaxFrames] + } + + frames := make([]*pendingFrame, 0, len(txFrames)) + + for i, frame := range txFrames { + target := frame.ResolvedTarget(frameTx.Sender) + + pending := &pendingFrame{ + index: uint16(i), + mode: uint8(frame.Mode), + flags: frame.Flags, + target: target, + value: new(big.Int), + dataLen: uint32(len(frame.Data)), + execGasLimit: frame.Limits.Execution, + stateGasLimit: frame.Limits.State, + } + + if frame.Value != nil { + pending.value = frame.Value.ToBig() + } + + if len(frame.Data) >= 4 { + pending.methodID = frame.Data[:4] + } + + // The receipt reports one result per frame, in frame order. + if extra != nil && i < len(extra.Frames) { + result := extra.Frames[i] + pending.hasResult = true + pending.status = result.Status + pending.execGasUsed = result.ExecutionGas + pending.stateGasUsed = result.StateGas + pending.logCount = uint32(len(result.Logs)) + } + + // The account is ensured only for a frame that ran. A frame skipped by an + // earlier failure called nobody, and registering its target would create an + // account whose first sighting is a call that never happened - funded, by this + // block, from a transfer that did not occur. Nothing downstream needs it either: + // both the aggregates and the value transfers require the frame to have run. + if pending.executed() { + pending.toAccount = ctx.ensureAccount(target, fromAccount, false) + } + + frames = append(frames, pending) + } + + markUndoneFrames(frames, extra, frameTx) + + return frames +} + +// markUndoneFrames flags the frames whose effects did not survive. +// +// A frame's status says whether it ran, not whether what it did lasted: an atomic batch +// that fails is unrolled, and a failing POST_TX frame reverts the whole execution body. +// Both rules live in txtypes, which owns them, so the answer is asked for rather than +// restated here. +func markUndoneFrames(frames []*pendingFrame, extra *txtypes.FrameReceiptExtra, frameTx *txtypes.FrameTx) { + if extra == nil { + return + } + + durable := extra.DurableFrames(frameTx) + + for i, frame := range frames { + if i >= len(durable) { + break + } + + // Only a frame that succeeded had anything taken back from it. + frame.rolledBack = frame.hasResult && frame.status == txtypes.FrameStatusSuccess && !durable[i] + } +} + +// aggregateFrames builds the per-account internal-transaction aggregates of a frame +// transaction from its frames. +// +// The frames come from the receipt, so this is what a frame transaction's account +// activity is built from whether or not tracing runs. A call trace contributes only the +// calls made from within the frames; its own roots are the frames again and are left out +// of it, so nothing is counted twice. +// +// Every frame is attributed to the transaction's sender as caller. DEFAULT and VERIFY +// frames are entered by the ENTRY_POINT predeploy rather than by the sender, but that +// address is a protocol placeholder that never holds code, and recording it as a +// participant would collect every frame transaction on the chain onto one account. +func (ctx *txProcessingContext) aggregateFrames( + frames []*pendingFrame, + senderAccount *pendingAccount, +) map[*pendingAccount]*pendingInternalAggregate { + aggregates := make(map[*pendingAccount]*pendingInternalAggregate, len(frames)+1) + + getAgg := func(account *pendingAccount) *pendingInternalAggregate { + agg, ok := aggregates[account] + if !ok { + agg = &pendingInternalAggregate{account: account} + aggregates[account] = agg + } + + return agg + } + + for _, frame := range frames { + // A frame that never ran touched nothing. + if !frame.executed() { + continue + } + + gasUsed := frame.execGasUsed + frame.stateGasUsed + + // A rolled-back frame spent its gas but moved no value. + value := 0.0 + if frame.succeeded() && frame.value.Sign() > 0 { + value = weiToFloat(frame.value, 18) + } + + if frame.toAccount == senderAccount { + // The frame addresses the sender, which is both caller and callee. + agg := getAgg(senderAccount) + agg.inCount++ + agg.outCount++ + agg.callTypeMask |= 1 << bdbtypes.CallTypeFrame + agg.valueIn += value + agg.valueOut += value + agg.gasUsed += gasUsed + + continue + } + + fromAgg := getAgg(senderAccount) + fromAgg.outCount++ + fromAgg.valueOut += value + fromAgg.gasUsed += gasUsed + + toAgg := getAgg(frame.toAccount) + toAgg.inCount++ + toAgg.callTypeMask |= 1 << bdbtypes.CallTypeFrame + toAgg.valueIn += value + toAgg.gasUsed += gasUsed + } + + return aggregates +} + +// correlateFrameTrace reports whether a frame transaction's call-trace roots are its +// frames. +// +// EIP-8141 makes a transaction a list of calls rather than one, so a client that traces +// such a transaction has one top-level call per frame that executed. Nothing specifies +// this: the callTracer is not part of execution-apis and EIP-8141 says nothing about +// debug tracing. The mapping is therefore only claimed once it verifies - one root per +// executed frame, each addressing that frame's resolved target - and refused otherwise. +// +// ethrex, the first client to ship the type, reports one self-addressed childless +// placeholder for the whole transaction instead. It carries no frame information and does +// not verify, so a caller falls back to the frames the receipt already describes. +func correlateFrameTrace(frames []*pendingFrame, roots []*exerpc.CallTraceCall) bool { + executed := make([]*pendingFrame, 0, len(frames)) + + for _, frame := range frames { + // A frame that never ran makes no call, so the trace holds nothing for it. + if frame.executed() { + executed = append(executed, frame) + } + } + + if len(roots) == 0 || len(roots) != len(executed) { + return false + } + + for i, root := range roots { + if root.To != executed[i].target { + return false + } + } + + return true +} + +// mergeInternalAggregates folds src into dst and returns the combined set. Accounts are +// keyed by the pending entry a block shares for one address, so the same account resolves +// to the same key in both. +func mergeInternalAggregates(dst, src map[*pendingAccount]*pendingInternalAggregate) map[*pendingAccount]*pendingInternalAggregate { + if len(dst) == 0 { + return src + } + + for account, agg := range src { + existing, ok := dst[account] + if !ok { + dst[account] = agg + + continue + } + + existing.inCount += agg.inCount + existing.outCount += agg.outCount + existing.callTypeMask |= agg.callTypeMask + existing.valueIn += agg.valueIn + existing.valueOut += agg.valueOut + existing.gasUsed += agg.gasUsed + } + + return dst +} diff --git a/indexer/execution/txindexer/process_frames_test.go b/indexer/execution/txindexer/process_frames_test.go new file mode 100644 index 000000000..a764f6c24 --- /dev/null +++ b/indexer/execution/txindexer/process_frames_test.go @@ -0,0 +1,682 @@ +package txindexer + +import ( + "io" + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/common" + bdbtypes "github.com/ethpandaops/dora/blockdb/types" + exerpc "github.com/ethpandaops/dora/clients/execution/rpc" + "github.com/ethpandaops/dora/dbtypes" + "github.com/ethpandaops/spamoor/txtypes" + "github.com/holiman/uint256" + "github.com/sirupsen/logrus" +) + +var ( + frameSender = common.HexToAddress("0x6df35438a4dfcdbd25c7a364ab77e3cfdce87fc5") + framePaymaster = common.HexToAddress("0x1111111111111111111111111111111111111111") + frameCallee = common.HexToAddress("0x30592ef78d262bc79f0fe46355e07a51d685e382") + expiryVerifier = common.HexToAddress("0x0000000000000000000000000000000000008141") +) + +// newFrameTestContext builds the minimum processing context the frame helpers need. +// Neither resolveFrames nor aggregateFrames performs I/O: accounts are only recorded for +// batch resolution later. +func newFrameTestContext() *txProcessingContext { + logger := logrus.New() + logger.SetOutput(io.Discard) + + return &txProcessingContext{ + accounts: make(map[common.Address]*pendingAccount, 8), + block: &BlockRef{BlockUID: 1}, + indexer: &TxIndexer{logger: logger}, + } +} + +// frameReceipt builds a receipt carrying the given per-frame results. +func frameReceipt(payer common.Address, results ...*txtypes.FrameReceipt) *txtypes.Receipt { + return &txtypes.Receipt{ + Type: txtypes.FrameTxType, + Extra: &txtypes.FrameReceiptExtra{Payer: payer, Frames: results}, + } +} + +// A frame transaction's frames carry their own targets, values and gas budgets, and the +// receipt reports one result per frame in the same order. +func TestResolveFramesPairsReceiptResults(t *testing.T) { + ctx := newFrameTestContext() + + frameTx := &txtypes.FrameTx{ + Sender: frameSender, + Frames: []*txtypes.Frame{ + // Expiry check against the deadline predeploy. + {Mode: txtypes.FrameModeVerify, Target: &expiryVerifier, Value: uint256.NewInt(0)}, + // A frame with no target addresses the transaction's sender. + {Mode: txtypes.FrameModeVerify, Flags: txtypes.ApproveExecutionAndPayment, Value: uint256.NewInt(0)}, + // The user's own operation, carrying value. + {Mode: txtypes.FrameModeSender, Target: &frameCallee, Value: uint256.NewInt(7), Data: []byte{0xde, 0xad, 0xbe, 0xef, 0x01}}, + }, + } + + receipt := frameReceipt(framePaymaster, + &txtypes.FrameReceipt{Status: txtypes.FrameStatusSuccess, ExecutionGas: 51}, + &txtypes.FrameReceipt{Status: txtypes.FrameStatusSuccess}, + &txtypes.FrameReceipt{Status: txtypes.FrameStatusSuccess, ExecutionGas: 21000, StateGas: 5, Logs: []*txtypes.Log{{}, {}}}, + ) + + frames := ctx.resolveFrames(frameTx, receipt, nil) + + if len(frames) != 3 { + t.Fatalf("frames = %d, want 3", len(frames)) + } + + if frames[0].target != expiryVerifier { + t.Errorf("frame 0 target = %s, want the expiry verifier", frames[0].target.Hex()) + } + + // A frame that declares no target resolves to the sender. + if frames[1].target != frameSender { + t.Errorf("frame 1 target = %s, want the sender %s", frames[1].target.Hex(), frameSender.Hex()) + } + + if frames[2].target != frameCallee { + t.Errorf("frame 2 target = %s, want %s", frames[2].target.Hex(), frameCallee.Hex()) + } + + if got := frames[2].value; got.Cmp(big.NewInt(7)) != 0 { + t.Errorf("frame 2 value = %s, want 7", got) + } + + if got := frames[0].execGasUsed; got != 51 { + t.Errorf("frame 0 execution gas = %d, want 51", got) + } + + if got := frames[2].stateGasUsed; got != 5 { + t.Errorf("frame 2 state gas = %d, want 5", got) + } + + if got := frames[2].logCount; got != 2 { + t.Errorf("frame 2 log count = %d, want 2", got) + } + + if got := frames[2].methodID; len(got) != 4 || got[0] != 0xde { + t.Errorf("frame 2 method id = %x, want the first four calldata bytes", got) + } + + if got := frames[2].dataLen; got != 5 { + t.Errorf("frame 2 data length = %d, want 5", got) + } + + // Every distinct target gets an account tracked for batch resolution. + for _, addr := range []common.Address{expiryVerifier, frameSender, frameCallee} { + if _, ok := ctx.accounts[addr]; !ok { + t.Errorf("no account tracked for frame target %s", addr.Hex()) + } + } +} + +// A client that reports fewer results than there are frames leaves the remaining frames +// without one. They must not be read as having failed, which is what status 0 would say. +func TestResolveFramesToleratesShortReceipt(t *testing.T) { + ctx := newFrameTestContext() + + frameTx := &txtypes.FrameTx{ + Sender: frameSender, + Frames: []*txtypes.Frame{ + {Mode: txtypes.FrameModeSender, Target: &frameCallee, Value: uint256.NewInt(0)}, + {Mode: txtypes.FrameModeSender, Target: &frameCallee, Value: uint256.NewInt(0)}, + }, + } + + receipt := frameReceipt(common.Address{}, + &txtypes.FrameReceipt{Status: txtypes.FrameStatusSuccess}, + ) + + frames := ctx.resolveFrames(frameTx, receipt, nil) + + if !frames[0].hasResult || !frames[0].succeeded() { + t.Error("frame 0 should carry the reported success") + } + + if frames[1].hasResult { + t.Error("frame 1 must not claim a result the receipt did not report") + } + + if frames[1].executed() || frames[1].succeeded() { + t.Error("a frame with no reported result is neither executed nor successful") + } +} + +// The batch rules are txtypes'; what is checked here is that a frame is called undone +// only when a success of its own was taken back. +func TestMarkUndoneFrames(t *testing.T) { + const batched = txtypes.AtomicBatchFlag + + tests := []struct { + name string + flags []uint8 + status []uint64 + want []bool + }{ + { + // A frame that fails on its own is a failure, not a batch that rolled back: + // it has no success to lose and nothing else went down with it. + name: "no batches, independent failure", + flags: []uint8{0, 0, 0}, + status: []uint64{txtypes.FrameStatusSuccess, txtypes.FrameStatusFailed, txtypes.FrameStatusSuccess}, + want: []bool{false, false, false}, + }, + { + // Only the success before the failure had anything taken back from it. The + // frame that failed and the one that never ran are told by their own status. + name: "batch rolls back the successes before the failure", + flags: []uint8{batched, batched, 0}, + status: []uint64{txtypes.FrameStatusSuccess, txtypes.FrameStatusFailed, txtypes.FrameStatusSkipped}, + want: []bool{true, false, false}, + }, + { + name: "batch that fully succeeds survives", + flags: []uint8{batched, batched, 0}, + status: []uint64{txtypes.FrameStatusSuccess, txtypes.FrameStatusSuccess, txtypes.FrameStatusSuccess}, + want: []bool{false, false, false}, + }, + { + name: "only the failing batch rolls back", + flags: []uint8{batched, 0, batched, 0}, + status: []uint64{txtypes.FrameStatusSuccess, txtypes.FrameStatusSuccess, txtypes.FrameStatusSuccess, txtypes.FrameStatusFailed}, + want: []bool{false, false, true, false}, + }, + { + name: "a trailing batch flag does not run past the end", + flags: []uint8{batched, batched}, + status: []uint64{txtypes.FrameStatusSuccess, txtypes.FrameStatusFailed}, + want: []bool{true, false}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + frames := make([]*pendingFrame, len(tt.flags)) + for i := range tt.flags { + frames[i] = &pendingFrame{ + index: uint16(i), + flags: tt.flags[i], + hasResult: true, + status: tt.status[i], + value: new(big.Int), + } + } + + frameTx := &txtypes.FrameTx{Frames: make([]*txtypes.Frame, len(frames))} + extra := &txtypes.FrameReceiptExtra{Frames: make([]*txtypes.FrameReceipt, len(frames))} + + for i := range frames { + frameTx.Frames[i] = &txtypes.Frame{Flags: tt.flags[i], Value: uint256.NewInt(0)} + extra.Frames[i] = &txtypes.FrameReceipt{Status: tt.status[i]} + } + + markUndoneFrames(frames, extra, frameTx) + + for i, want := range tt.want { + if frames[i].rolledBack != want { + t.Errorf("frame %d rolledBack = %v, want %v", i, frames[i].rolledBack, want) + } + } + }) + } +} + +// A frame transaction's per-account rows come from its frames: the sender is the caller +// of each, and every target it addressed is reachable from the transaction. +func TestAggregateFramesAttributesFramesToTheirTargets(t *testing.T) { + ctx := newFrameTestContext() + sender := ctx.ensureAccount(frameSender, nil, false) + callee := ctx.ensureAccount(frameCallee, nil, false) + + frames := []*pendingFrame{ + {index: 0, target: frameCallee, toAccount: callee, value: big.NewInt(5), hasResult: true, status: txtypes.FrameStatusSuccess, execGasUsed: 100, stateGasUsed: 20}, + {index: 1, target: frameCallee, toAccount: callee, value: new(big.Int), hasResult: true, status: txtypes.FrameStatusSuccess, execGasUsed: 50}, + } + + aggregates := ctx.aggregateFrames(frames, sender) + + senderAgg := aggregates[sender] + if senderAgg == nil { + t.Fatal("sender has no aggregate") + } + + if senderAgg.outCount != 2 { + t.Errorf("sender out count = %d, want 2", senderAgg.outCount) + } + + if senderAgg.gasUsed != 170 { + t.Errorf("sender gas = %d, want 170", senderAgg.gasUsed) + } + + calleeAgg := aggregates[callee] + if calleeAgg == nil { + t.Fatal("callee has no aggregate") + } + + if calleeAgg.inCount != 2 { + t.Errorf("callee in count = %d, want 2", calleeAgg.inCount) + } + + if calleeAgg.callTypeMask != 1< existingNonce { - ctx.senderNonces[from] = txNonce + // Track sender's highest nonce for batch update. + // + // EIP-8250 gives a frame transaction one nonce sequence per key it names. Only the + // zero key aliases the sender's ordinary account nonce; any other key sequences the + // transaction in a domain of its own, and recording that sequence as an account + // nonce would corrupt the account index. + if !isFrameTx || frameTx.UsesLegacyNonce() { + if existingNonce, exists := ctx.senderNonces[from]; !exists || txNonce > existingNonce { + ctx.senderNonces[from] = txNonce + } } // Calculate gas prices in Gwei (1 Gwei = 10^9 wei) @@ -378,7 +409,6 @@ func (ctx *txProcessingContext) processTransaction( BlockUid: ctx.block.BlockUID, TxHash: txHash[:], FromID: fromAccount.id, - ToID: toAccount.id, Nonce: txNonce, Amount: weiToFloat(txValue, 18), // ETH uses 18 decimals AmountRaw: txValue.Bytes(), @@ -394,12 +424,40 @@ func (ctx *txProcessingContext) processTransaction( } result.reverted = receipt.Status == 0 + // A frame transaction has no revert of its own: it reaches the chain only once its + // validation frames succeed, so a receipt status of failure means that frames within it + // failed. The row keeps how many, which is the only part of the per-frame results the + // list pages have. + if isFrameTx { + result.revertReason = frameFailureSummary(result.frames) + } + // Store pending accounts for resolving IDs at commit time result.fromAccount = fromAccount result.toAccount = toAccount - // Track ETH transfer for balance updates (only if successful and value > 0) - if receipt.Status == 1 && txValue.Sign() > 0 { + // Track ETH transfer for balance updates (only if successful and value > 0). + // A frame transaction moves value per frame rather than once, so each frame that + // carried value and durably succeeded contributes its own transfer. + if isFrameTx { + for _, frame := range result.frames { + if !frame.succeeded() || frame.value.Sign() == 0 { + continue + } + + ctx.pendingTransfers = append(ctx.pendingTransfers, &pendingBalanceTransfer{ + fromAddr: from, + toAddr: frame.target, + tokenAddr: common.Address{}, // Zero address = native ETH + amount: frame.value, + isERC20: false, + tokenID: 0, // Native ETH + fromAccount: fromAccount, + toAccount: frame.toAccount, + token: nil, + }) + } + } else if receipt.Status == 1 && txValue.Sign() > 0 { ctx.pendingTransfers = append(ctx.pendingTransfers, &pendingBalanceTransfer{ fromAddr: from, toAddr: toAddr, @@ -425,15 +483,27 @@ func (ctx *txProcessingContext) processTransaction( big.NewInt(int64(receipt.GasUsed)), effectiveGasPrice, ) + + // The fee comes out of whoever settled it. A frame transaction names that account on + // its receipt, and for a sponsored transaction - the reason the field exists - it is + // a paymaster rather than the sender. + feeAddr := from + feeAccount := fromAccount + + if extra := receipt.FrameExtra(); extra != nil && extra.Payer != (common.Address{}) { + feeAddr = extra.Payer + feeAccount = ctx.ensureAccount(feeAddr, nil, false) + } + if txFeeWei.Sign() > 0 { ctx.pendingTransfers = append(ctx.pendingTransfers, &pendingBalanceTransfer{ - fromAddr: from, + fromAddr: feeAddr, toAddr: common.Address{}, // No receiver - fee goes to validator/burned tokenAddr: common.Address{}, // Native ETH amount: txFeeWei, isERC20: false, tokenID: 0, // Native ETH - fromAccount: fromAccount, + fromAccount: feeAccount, toAccount: nil, // nil indicates fee-only deduction token: nil, }) @@ -451,14 +521,45 @@ func (ctx *txProcessingContext) processTransaction( } // 6. Process call trace if available (Mode Full + tracesEnabled) - if callTrace != nil { - result.callTraceData, result.internalAggregates = ctx.processCallTrace(callTrace, fromAccount) + framesTraced := false + + if len(callTrace) > 0 { + if isFrameTx { + framesTraced = correlateFrameTrace(result.frames, callTrace) + } + + callTraceData, aggregates := ctx.processCallTrace(callTrace, fromAccount) + + if !isFrameTx || framesTraced { + result.callTraceData = callTraceData + result.internalAggregates = aggregates + } else { + // The trace says nothing about the frames it was asked about. Keeping it + // would show a call the transaction never made. + ctx.indexer.logger.WithFields(logrus.Fields{ + "txHash": txHash.Hex(), + "frames": len(result.frames), + "roots": len(callTrace), + }).Debug("call trace does not decompose into the transaction's frames, discarding it") + } + } + + // A frame transaction's frames always come from its receipt, so its account activity + // reads the same whether or not tracing runs and whichever client served the trace. + // The trace adds only the calls made from within the frames. + if isFrameTx { + result.internalAggregates = mergeInternalAggregates( + result.internalAggregates, + ctx.aggregateFrames(result.frames, fromAccount), + ) + result.internalFromFrames = true } // Decode the revert reason from the root call frame (index 0 = depth 0). // Only available when traces were collected; otherwise the reason stays empty - // and the tx maps to the "unknown" sentinel at commit time. - if result.reverted && len(result.callTraceData) > 0 { + // and the tx maps to the "unknown" sentinel at commit time. A frame transaction's + // reason is its frame failure summary, set above. + if result.reverted && !isFrameTx && len(result.callTraceData) > 0 { result.revertReason, result.revertReservedID = decodeRevertReason(result.callTraceData[0]) } @@ -716,7 +817,7 @@ func (ctx *txProcessingContext) resolveTokensFromDB() error { // processEvent collects event data for the event index (DB) and blockdb storage. func (ctx *txProcessingContext) processEvent( index uint32, - log *types.Log, + log *txtypes.Log, funderAccount *pendingAccount, ) *pendingTxEvent { // Ensure source account exists @@ -754,7 +855,7 @@ func (ctx *txProcessingContext) processEvent( func (ctx *txProcessingContext) detectTokenTransfers( eventIndex uint32, txPos uint32, - log *types.Log, + log *txtypes.Log, funderAccount *pendingAccount, ) []*pendingTokenTransfer { if len(log.Topics) == 0 { @@ -794,7 +895,7 @@ func (ctx *txProcessingContext) detectTokenTransfers( func (ctx *txProcessingContext) parseERC20or721Transfer( eventIndex uint32, txPos uint32, - log *types.Log, + log *txtypes.Log, funderAccount *pendingAccount, ) *pendingTokenTransfer { // Need at least 3 topics for ERC20/721 @@ -834,7 +935,7 @@ func (ctx *txProcessingContext) parseERC20or721Transfer( func (ctx *txProcessingContext) parseERC1155TransferSingle( eventIndex uint32, txPos uint32, - log *types.Log, + log *txtypes.Log, funderAccount *pendingAccount, ) *pendingTokenTransfer { // Need 4 topics and at least 64 bytes of data @@ -860,7 +961,7 @@ func (ctx *txProcessingContext) parseERC1155TransferSingle( func (ctx *txProcessingContext) parseERC1155TransferBatch( eventIndex uint32, txPos uint32, - log *types.Log, + log *txtypes.Log, funderAccount *pendingAccount, ) []*pendingTokenTransfer { // Need 4 topics and data for arrays @@ -1216,7 +1317,13 @@ func (ctx *txProcessingContext) commitTransaction(commitCtx context.Context, dbT // 3. Insert transaction (resolve account IDs now that they're set) if result.transaction != nil { result.transaction.FromID = result.fromAccount.id - result.transaction.ToID = result.toAccount.id + + // A transaction that addresses more than one recipient has none of its own, and + // keeps the id 0 that no account carries. Its targets are reachable through its + // frames and through the per-account rows written below. + if result.toAccount != nil { + result.transaction.ToID = result.toAccount.id + } // Resolve the revert reason to a revert_id. Well-known EVM errors use a // reserved id directly; other reverts dedup into el_revert_reason; reverts @@ -1268,9 +1375,14 @@ func (ctx *txProcessingContext) commitTransaction(commitCtx context.Context, dbT } } - // 6. Insert per-account internal-tx aggregates (Mode Full + tracesEnabled). + // 6. Insert per-account internal-tx aggregates. // One row per touched account regardless of how many sub-calls involved it. - if ctx.indexer.mode == ModeFull && utils.Config.ExecutionIndexer.TracesEnabled && len(result.internalAggregates) > 0 { + // + // Call-trace aggregates exist only where tracing runs. A frame transaction's come + // from its receipt, so they are written wherever the transaction itself is - without + // them a frame transaction would appear on no address page but its sender's. + tracesAvailable := ctx.indexer.mode == ModeFull && utils.Config.ExecutionIndexer.TracesEnabled + if len(result.internalAggregates) > 0 && (result.internalFromFrames || tracesAvailable) { internalEntries := make([]*dbtypes.ElTransactionInternal, 0, len(result.internalAggregates)) for _, agg := range result.internalAggregates { inCount := agg.inCount @@ -1306,16 +1418,15 @@ func (ctx *txProcessingContext) commitTransaction(commitCtx context.Context, dbT // and builds per-account aggregates over sub-calls (skipping index 0) for // the DB index. func (ctx *txProcessingContext) processCallTrace( - traceResult *exerpc.CallTraceCall, + roots []*exerpc.CallTraceCall, funderAccount *pendingAccount, ) ([]bdbtypes.FlatCallFrame, map[*pendingAccount]*pendingInternalAggregate) { - if traceResult == nil { + if len(roots) == 0 { return nil, nil } frames := make([]bdbtypes.FlatCallFrame, 0, 16) aggregates := make(map[*pendingAccount]*pendingInternalAggregate, 8) - callIdx := uint32(0) getAgg := func(acc *pendingAccount) *pendingInternalAggregate { agg, ok := aggregates[acc] @@ -1328,9 +1439,6 @@ func (ctx *txProcessingContext) processCallTrace( var walkTrace func(call *exerpc.CallTraceCall, depth uint16) walkTrace = func(call *exerpc.CallTraceCall, depth uint16) { - currentIdx := callIdx - callIdx++ - // Determine call status status := uint8(bdbtypes.CallStatusSuccess) if call.Error != "" { @@ -1346,9 +1454,9 @@ func (ctx *txProcessingContext) processCallTrace( // frame-local gas (this frame's execution only) by subtracting direct // children's cumulative gasUsed. Saturating subtract guards against // rounding/clamping quirks from non-Geth tracers. - selfGas := uint64(call.GasUsed) + selfGas := call.TotalGasUsed() for i := range call.Calls { - childGas := uint64(call.Calls[i].GasUsed) + childGas := call.Calls[i].TotalGasUsed() if childGas >= selfGas { selfGas = 0 break @@ -1374,9 +1482,13 @@ func (ctx *txProcessingContext) processCallTrace( frames = append(frames, frame) - // Aggregate per touched account (skip index 0 = top-level call, - // which duplicates el_transactions). - if currentIdx > 0 { + // Aggregate per touched account, leaving out the root calls. An ordinary + // transaction's root restates what el_transactions already holds. A decomposed + // frame transaction's roots are its frames, which the receipt describes and + // aggregateFrames records - taking them here too would count each frame twice, + // and would enter ENTRY_POINT, the predeploy that calls DEFAULT and VERIFY + // frames, as an account that took part in the transaction. + if depth > 0 { fromAccount := ctx.ensureAccount(call.From, funderAccount, false) toAccount := ctx.ensureAccount(call.To, fromAccount, false) callType := exerpc.CallTypeFromString(call.Type) @@ -1419,7 +1531,10 @@ func (ctx *txProcessingContext) processCallTrace( } } - walkTrace(traceResult, 0) + for _, root := range roots { + walkTrace(root, 0) + } + return frames, aggregates } @@ -1471,9 +1586,11 @@ func (ctx *txProcessingContext) buildExecDataObject() ([]byte, uint16) { } } - // Encode receipt metadata section + // Encode receipt metadata section, with the frame content of a frame + // transaction after it. The frames' own fields come back from the transaction in + // the beacon block; who paid and what each frame did are only on the receipt. if result.receiptMeta != nil { - raw, err := ds.MarshalSSZ(result.receiptMeta) + raw, err := bdbtypes.EncodeReceiptMetaSection(result.receiptMeta, frameReceiptData(result.frames, result.framePayer)) if err != nil { return nil, 0 } diff --git a/indexer/execution/txindexer/txindexer.go b/indexer/execution/txindexer/txindexer.go index d7ef97839..5180a9052 100644 --- a/indexer/execution/txindexer/txindexer.go +++ b/indexer/execution/txindexer/txindexer.go @@ -792,7 +792,7 @@ func (t *TxIndexer) checkAndRunCleanup() { durationMs := time.Since(start).Milliseconds() // Build per-object stats for this cleanup cycle. - objects := make([]ElPruningObjectStat, 0, 7) + objects := make([]ElPruningObjectStat, 0, 8) addObj := func(typ string, deleted, sizeBytes int64) { if deleted > 0 || sizeBytes > 0 { objects = append(objects, ElPruningObjectStat{Type: typ, Deleted: deleted, SizeBytes: sizeBytes}) diff --git a/static/css/layout.css b/static/css/layout.css index 928fb2b87..50d44b893 100644 --- a/static/css/layout.css +++ b/static/css/layout.css @@ -418,9 +418,9 @@ span.validator-label { margin-left: auto; } -/* Highlight equal address links across rows on hover (set by explorer.js). */ -.el-data-table .addr-cell a, -.itx-wrap .itx-addr a { +/* Highlight every link to the hovered address (set by explorer.js), so the same account + can be followed through a page that mentions it more than once. */ +a[href^="/address/0x"] { border-radius: 3px; transition: background-color 0.1s; } diff --git a/static/js/explorer.js b/static/js/explorer.js index da868e77d..a5fe0174a 100644 --- a/static/js/explorer.js +++ b/static/js/explorer.js @@ -252,9 +252,12 @@ }); } - // initAddrHighlight highlights all equal address links within the hovered - // scope (an EL data table or the internal-tx tree). Uses event delegation so - // it also covers rows/nodes that are loaded lazily after page load. + // initAddrHighlight highlights every link to the address being hovered, so the same + // account can be followed through a page that mentions it in several places - the + // frames of a transaction, its state changes, the row it came from. It prefers the + // nearest list (an EL data table or the internal-tx tree) and otherwise takes the + // whole page, since an address that appears twice on a page is the same account + // wherever it appears. Uses event delegation so lazily loaded content is covered. function initAddrHighlight() { var current = null; function clear() { @@ -265,8 +268,7 @@ document.addEventListener('mouseover', function(ev) { var a = ev.target.closest ? ev.target.closest('a[href^="/address/0x"]') : null; if (!a) { return; } - var scope = a.closest('.el-data-table, .itx-wrap'); - if (!scope) { return; } + var scope = a.closest('.el-data-table, .itx-wrap') || document.body; var href = a.getAttribute('href'); if (href === current) { return; } clear(); @@ -701,7 +703,9 @@ header: '

Transactions:

', suggestion: function (data) { var status = ""; - if (data.reverted) { + if (data.frame_incomplete) { + status = `Complete`; + } else if (data.reverted) { status = `Failed`; } var blockInfo = data.block_number ? `Block ${data.block_number}` : ""; diff --git a/templates/address/transactions.html b/templates/address/transactions.html index 5c1f73a36..339467dec 100644 --- a/templates/address/transactions.html +++ b/templates/address/transactions.html @@ -173,6 +173,8 @@ {{ formatEthAddressShortLink .ToAddr .ToIsContract }} {{ end }} + {{ else if .IsMultiTarget }} + multiple targets {{ else }} {{ formatContractCreationLink .FromAddr .Nonce }} {{ end }} @@ -181,13 +183,17 @@ {{ formatTransactionFee .TxFee }} {{ if .BlockOrphaned }} - {{ if .Reverted }} + {{ if and .Reverted .IsMultiTarget }} + Orphaned + {{ else if .Reverted }} Orphaned {{ else }} Orphaned {{ end }} {{ else }} - {{ if .Reverted }} + {{ if and .Reverted .IsMultiTarget }} + Complete + {{ else if .Reverted }} Reverted {{ else }} Success diff --git a/templates/builder/builder.html b/templates/builder/builder.html index 577ef03d8..47c0b6940 100644 --- a/templates/builder/builder.html +++ b/templates/builder/builder.html @@ -286,20 +286,21 @@

Builder function onTabSelected(event) { event.preventDefault(); - var tabId = event.target.getAttribute('data-lazy-tab'); + var link = event.currentTarget; + var tabId = link.getAttribute('data-lazy-tab'); var tabEl = document.getElementById(tabId); if (!$(tabEl).data("loaded")) { - $.get(event.target.getAttribute('href') + "&lazy=true", function(data) { + $.get(link.getAttribute('href') + "&lazy=true", function(data) { $(tabEl).html(data); $(tabEl).data("loaded", true); explorer.initControls(); }); } - var tab = new bootstrap.Tab(tabEl); + var tab = new bootstrap.Tab(link); tab.show(); - window.history.replaceState(null, document.title, "/builder/{{ .RouteKey }}" + event.target.getAttribute('href')); + window.history.replaceState(null, document.title, "/builder/{{ .RouteKey }}" + link.getAttribute('href')); } }); diff --git a/templates/consolidations/consolidations.html b/templates/consolidations/consolidations.html index ed0a41b83..c3a77f92f 100644 --- a/templates/consolidations/consolidations.html +++ b/templates/consolidations/consolidations.html @@ -361,7 +361,7 @@

This table displays pending consolidations in the que }); } - var tab = new bootstrap.Tab(tabEl); + var tab = new bootstrap.Tab(link); tab.show(); window.history.replaceState(null, document.title, "/validators/consolidations" + link.getAttribute('href')); diff --git a/templates/deposits/deposits.html b/templates/deposits/deposits.html index de56033ab..348ae23e9 100644 --- a/templates/deposits/deposits.html +++ b/templates/deposits/deposits.html @@ -627,7 +627,7 @@
This table displays deposits waiting to be activated }); } - var tab = new bootstrap.Tab(tabEl); + var tab = new bootstrap.Tab(link); tab.show(); window.history.replaceState(null, document.title, "/validators/deposits" + link.getAttribute('href')); diff --git a/templates/exits/exits.html b/templates/exits/exits.html index 8908ee6f8..883566391 100644 --- a/templates/exits/exits.html +++ b/templates/exits/exits.html @@ -436,7 +436,7 @@
This table displays the most recent execution layer t }); } - var tab = new bootstrap.Tab(tabEl); + var tab = new bootstrap.Tab(link); tab.show(); window.history.replaceState(null, document.title, "/validators/exits" + link.getAttribute('href')); diff --git a/templates/slot/transactions.html b/templates/slot/transactions.html index 869e0e128..710fb120b 100644 --- a/templates/slot/transactions.html +++ b/templates/slot/transactions.html @@ -32,7 +32,9 @@ - {{ if gt (len $transaction.To) 0 }} + {{ if $transaction.IsMultiTarget }} + {{ $transaction.FrameCount }} frame{{ if ne $transaction.FrameCount 1 }}s{{ end }} + {{ else if gt (len $transaction.To) 0 }} {{ formatEthAddressShortLink $transaction.To false }} {{ else }} @@ -51,7 +53,9 @@ {{ formatTransactionValue $transaction.Value }} {{ if $transaction.HasElData }} - {{ if $transaction.Reverted }} + {{ if and $transaction.Reverted $transaction.IsMultiTarget }} + Complete + {{ else if $transaction.Reverted }} Failed {{ else }} Success diff --git a/templates/transaction/authorizations.html b/templates/transaction/authorizations.html index b838ac01c..45d0c1dc9 100644 --- a/templates/transaction/authorizations.html +++ b/templates/transaction/authorizations.html @@ -19,7 +19,7 @@ {{ if .AuthorityOk }} {{ $authAddr := formatEthAddressFull .AuthorityAddr }} - {{ $authAddr }} + {{ formatEthAddressFullLink .AuthorityAddr }} {{ else }} Recovery failed @@ -27,7 +27,7 @@ {{ $delegateAddr := formatEthAddressFull .DelegateAddr }} - {{ $delegateAddr }} + {{ formatEthAddressFullLink .DelegateAddr }} diff --git a/templates/transaction/events.html b/templates/transaction/events.html index 183563b01..e3c3c2fbb 100644 --- a/templates/transaction/events.html +++ b/templates/transaction/events.html @@ -33,6 +33,9 @@ {{ $evt.EventIndex }} + {{ if $evt.HasFrame }} +
frame #{{ $evt.FrameIndex }} + {{ end }} {{ if $evt.EventName }}
{{ $evt.EventName }} {{ if $evt.EthTransferValue }} @@ -46,7 +49,7 @@ {{ if gt (len $evt.SourceAddr) 0 }} {{ $addr := formatEthAddressFull $evt.SourceAddr }} {{ if $evt.SourceIsContract }}{{ end }} - {{ $addr }} + {{ formatEthAddressFullLink $evt.SourceAddr }} {{ else }} Unknown {{ end }} @@ -60,11 +63,11 @@ {{ $evt.EthTransferValue }}
From: - {{ formatEthAddressFull $evt.EthTransferFrom }} + {{ formatEthAddressFullLink $evt.EthTransferFrom }}
To: - {{ formatEthAddressFull $evt.EthTransferTo }} + {{ formatEthAddressFullLink $evt.EthTransferTo }}
{{ end }} diff --git a/templates/transaction/frames.html b/templates/transaction/frames.html new file mode 100644 index 000000000..6cc6470ac --- /dev/null +++ b/templates/transaction/frames.html @@ -0,0 +1,525 @@ +{{ define "frames" }} +
+
+ {{ if eq (len .Frames) 0 }} +
+ +

Neither this transaction nor its receipt is retained for this block, so its frames cannot be read.

+
+ {{ else }} + + + {{/* ---------- what this transaction is, and how a frame transaction works ---------- */}} +
+
+ {{ .FrameCount }} frame{{ if ne .FrameCount 1 }}s{{ end }} + {{ if .FrameShape }} + {{ .FrameShape }} + {{ end }} + {{ if .FrameExtensions }} + {{ .FrameExtensions }} + {{ end }} + {{ if .FrameBodyReverted }} + + body reverted + + {{ end }} + {{ if .HasExpiry }} + {{ if .ExpiryPassed }} + + deadline had passed {{ .ExpiryMargin }} before inclusion + + {{ else if .ExpiryMargin }} + + included {{ .ExpiryMargin }} before its deadline + + {{ end }} + {{ end }} + {{ if .FrameResultsMissing }} + + declared only + + {{ end }} +
+ +
+
+ Results + + {{ if .FrameResultsMissing }} + not retained + {{ else }} + {{ .FrameSuccessCount }} ok{{ if gt .FrameFailedCount 0 }}, {{ .FrameFailedCount }} failed{{ end }}{{ if gt .FrameSkippedCount 0 }}, {{ .FrameSkippedCount }} skipped{{ end }} + {{ end }} + +
+
+ Execution gas + {{ formatAddCommas .FrameExecGasUsed }} +
+
+ State gas + {{ formatAddCommas .FrameStateGasUsed }} +
+
+ Sender + {{ formatEthAddressShortLink .FromAddr false 5 }} +
+ {{ if and (gt (len .PayerAddr) 0) (not .PayerIsSender) }} +
+ Fee paid by + {{ formatEthAddressShortLink .PayerAddr false 5 }} +
+ {{ end }} + {{ if not .NonceIsAccount }} +
+ Nonce keys + {{ len .NonceKeys }} key{{ if ne (len .NonceKeys) 1 }}s{{ end }} at sequence {{ formatAddCommas .Nonce }} +
+ {{ end }} +
+ +
+ How a frame transaction works +
+
Ordered calls
+
A frame transaction is not one call but a list of them, run in order. Each frame names its own target, value and two gas budgets, and the receipt reports a result for each. The transaction has no recipient of its own.
+
Who calls
+
SENDER frames are entered by the sender. DEFAULT and VERIFY frames are entered by the ENTRY_POINT predeploy, which is what lets a verifier or a paymaster run without the sender calling it directly.
+
Validation first
+
The leading frames approve execution and payment. If one of them reverts the transaction is invalid and never reaches the chain, so every frame shown here got past that point.
+
Atomic batches
+
Frames can be batched so they take effect together. If one of a batch fails the whole batch is rolled back and the frames after it are skipped - neither a success nor a failure.
+
Envelope
+
EIP-8250 replaces the sender's nonce with a keyed sequence, and EIP-8272 lets a transaction declare recent roots for its frames to read. A chain can run either, both or neither, so the badge above says which shape this payload is.
+
Two gas dimensions
+
Each frame budgets execution gas and, under EIP-8037, state gas. State gas is a final attribution rather than a running total: a later frame can retroactively reduce an earlier one through a refill.
+
+
+
+ + {{/* ---------- the frames themselves ---------- */}} + {{ $split := and (gt .FrameValidationCount 0) (lt .FrameValidationCount (len .Frames)) }} + {{ range $idx, $frame := .Frames }} + + {{ if $split }} + {{ if eq $idx 0 }} +
+ Validation + — these frames settle whether the transaction runs and who pays for it +
+ {{ else if eq $idx $.FrameValidationCount }} +
+ Execution + — the operations the transaction was sent to perform +
+ {{ end }} + {{ end }} + + {{ if and $frame.IsBatchStart (gt $frame.BatchSize 1) }} +
+
+ + Atomic batch of {{ $frame.BatchSize }} frames — they take effect together or not at all + {{ if $frame.RolledBack }} + rolled back by frame #{{ $frame.BatchFailedIndex }} + {{ end }} +
+ {{ end }} + + {{ $hasPanel := or (gt (len $frame.Data) 0) $frame.HasTarget }} +
+
+ #{{ $frame.Index }} + + + {{ if $frame.Species }} + {{ $frame.Species }} + {{ end }} + {{ $frame.ModeName }} + {{ if $frame.ApprovesPayment }}{{ end }} + {{ if $frame.ApprovesExecution }}{{ end }} + + + + {{ if $frame.CallerIsSender }} + {{ formatEthAddressShortLink $frame.CallerAddr false 4 }} + {{ else }} + {{ if $frame.CallerLabel }}{{ $frame.CallerLabel }}{{ else }}{{ formatEthAddress $frame.CallerAddr }}{{ end }} + {{ end }} + + {{ if $frame.HasTarget }} + {{ if $frame.TargetLabel }} + {{ $frame.TargetLabel }} + {{ else }} + {{ formatEthAddressShortLink $frame.TargetAddr false 4 }} + {{ end }} + {{ if $frame.TargetIsSender }}(sender){{ end }} + {{ else }} + unknown + {{ end }} + + + {{ if gt $frame.DataLen 0 }} + + {{ if $frame.MethodName }}{{ $frame.MethodName }}{{ else if gt (len $frame.MethodID) 0 }}{{ formatHexBytes $frame.MethodID }}{{ else }}{{ $frame.DataLen }} B{{ end }} + + {{ end }} + + + + {{ if gt $frame.Amount 0.0 }}{{ formatTransactionValue $frame.Amount }}{{ end }} + + + + {{ if gt $frame.LogCount 0 }} {{ $frame.LogCount }}{{ end }} + + + + exec{{ formatAddCommas $frame.ExecGasUsed }} / {{ formatAddCommas $frame.ExecGasLimit }} + + + state{{ formatAddCommas $frame.StateGasUsed }} / {{ formatAddCommas $frame.StateGasLimit }} + + + + {{ if and $frame.RolledBack $.FrameBodyReverted }} + {{ $frame.StatusText }} + {{ else if $frame.RolledBack }} + {{ $frame.StatusText }} + {{ else if eq $frame.Status 1 }} + {{ $frame.StatusText }} + {{ else if eq $frame.Status 0 }} + {{ $frame.StatusText }} + {{ else if eq $frame.Status 2 }} + {{ $frame.StatusText }} + {{ else }} + {{ $frame.StatusText }} + {{ end }} + + + + {{ if $hasPanel }} + + {{ end }} + + +
+ + {{ if $hasPanel }} +
+ + + + + + + {{ if $frame.HasTarget }} + + + + + {{ end }} + + + + + + + + + {{ if $frame.HasExpiry }} + + + + + {{ end }} + {{ if gt $frame.BatchSize 1 }} + + + + + {{ end }} + +
Caller + {{ if $frame.CallerIsSender }}{{ formatEthAddressFullLink $frame.CallerAddr }} (the sender){{ else }}{{ formatEthAddressFull $frame.CallerAddr }} ({{ if $frame.CallerLabel }}{{ $frame.CallerLabel }}, {{ end }}a predeploy with no code){{ end }} +
Target + {{ formatEthAddressFullLink $frame.TargetAddr }} + + {{ if $frame.TargetLabel }}{{ $frame.TargetLabel }}{{ end }} +
Gas + {{ formatAddCommas $frame.ExecGasUsed }} / {{ formatAddCommas $frame.ExecGasLimit }} execution +  ·  + {{ formatAddCommas $frame.StateGasUsed }} / {{ formatAddCommas $frame.StateGasLimit }} state +
Approves + {{ if or $frame.ApprovesPayment $frame.ApprovesExecution }} + {{ if $frame.ApprovesPayment }}payment{{ end }}{{ if and $frame.ApprovesPayment $frame.ApprovesExecution }} and {{ end }}{{ if $frame.ApprovesExecution }}execution{{ end }} + {{ else }} + nothing — this frame only runs + {{ end }} +
Deadline + {{ $frame.ExpiryTime.UTC }} + — {{ if $.ExpiryPassed }}{{ $.ExpiryMargin }} before the transaction was included, which this frame should have rejected{{ else if $.ExpiryMargin }}{{ $.ExpiryMargin }} after the transaction was included; this frame reverts once it passes, which would make the transaction invalid{{ else }}this frame reverts once it passes, which would make the transaction invalid{{ end }} +
Atomic batch + one of {{ $frame.BatchSize }} frames that take effect together + {{ if $frame.RolledBack }} — undone, because frame #{{ $frame.BatchFailedIndex }} failed{{ end }} +
+ + {{ if gt (len $frame.Data) 0 }} +
+ Calldata ({{ len $frame.Data }} bytes): + + {{ if $frame.DecodedCalldata }} +
+ + +
+ {{ end }} +
+ {{ if $frame.DecodedCalldata }} +
+ {{ if $frame.MethodSignature }}{{ $frame.MethodSignature }}{{ end }} + + + + {{ range $pi, $p := $frame.DecodedCalldata }} + + {{ end }} + +
#NameTypeValue
{{ $pi }}{{ $p.Name }}{{ $p.Type }}{{ $p.Value }}
+
+ + {{ else }} +
{{ formatHexBytes $frame.Data }}
+ {{ end }} + {{ end }} +
+ {{ end }} +
+ + {{ if and $frame.IsBatchEnd (gt $frame.BatchSize 1) }} +
+ {{ end }} + + {{ end }} + + {{/* ---------- EIP-8250 nonce domains ---------- */}} + {{ if .NonceKeys }} +
+
+ + Nonce keys + — EIP-8250. Every one of these had to be at sequence {{ formatAddCommas .Nonce }} for the transaction to be valid, and inclusion moved them all on together. +
+
+ + + + + + + + + {{ range $key := .NonceKeys }} + + + + + {{ end }} + +
#Key
{{ $key.Index }} + {{ $key.Key }} + +
+
+
+ {{ end }} + + {{/* ---------- EIP-8272 roots the transaction declared ---------- */}} + {{ if .FrameRecentRoots }} +
+
+ + Recent roots + — EIP-8272. Declaring a root up front is what lets a frame read it while the transaction runs. +
+
+ + + + + + + + + + + {{ range $root := .FrameRecentRoots }} + + + + + + + {{ end }} + +
#SlotSourceRoot
{{ $root.Index }}{{ formatAddCommas $root.Slot }}{{ formatHexBytes $root.SourceID }}{{ formatHexBytes $root.Root }}
+
+
+ {{ end }} + + + {{ end }} +
+
+{{ end }} diff --git a/templates/transaction/internaltxs.html b/templates/transaction/internaltxs.html index 64c00fa81..13ac4ddd4 100644 --- a/templates/transaction/internaltxs.html +++ b/templates/transaction/internaltxs.html @@ -1,7 +1,13 @@ {{ define "internaltxs" }}
- {{ if .InternalTxsNotAvailable }} + {{ if .FrameCallsNotTraced }} +
+ +

This client does not break a frame transaction's trace into its frames, so there is nothing here that says which frame made which call.

+

The transaction's own calls are its frames — see the Frames tab.

+
+ {{ else if .InternalTxsNotAvailable }}

Detailed internal transaction data is not available for this block (data may have been pruned).

@@ -82,6 +88,17 @@ .itx-decoded-table td, .itx-decoded-table th { padding: 2px 8px; vertical-align: top; } .itx-decoded-table th { font-weight: 600; white-space: nowrap; } .itx-decoded-table td.itx-decoded-val { word-break: break-all; font-family: monospace; } + .itx-frame-head { + display: flex; + align-items: center; + gap: 8px; + padding: 5px 10px; + background: var(--bs-tertiary-bg); + border-bottom: 1px solid var(--bs-border-color); + font-size: 0.78rem; + color: var(--bs-secondary); + } + .itx-frame-note { font-size: 0.8rem; } .itx-addrs { display: inline-flex; align-items: center; gap: 5px; } .itx-method { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 180px; } @media (min-width: 1400px) { @@ -100,6 +117,13 @@
{{ range $idx, $itx := .InternalTxs }} + {{ if and $itx.HasFrame (eq $itx.Depth 0) }} +
+ + Frame #{{ $itx.FrameIndex }} + — the calls below were made from this frame +
+ {{ end }} {{ $hasData := (and $itx.HasTraceData (or (gt (len $itx.Input) 0) (gt (len $itx.Output) 0))) }}
{{/* --- single row --- */}} diff --git a/templates/transaction/signatures.html b/templates/transaction/signatures.html new file mode 100644 index 000000000..039086dc0 --- /dev/null +++ b/templates/transaction/signatures.html @@ -0,0 +1,158 @@ +{{ define "signatures" }} +
+
+ {{ if eq (len .Signatures) 0 }} +
+ +

The transaction itself is not retained for this block, so what signed it cannot be read.

+
+ {{ else }} + + +
+ {{ if .SignaturesRecoverSender }} + This transaction is signed once, and its sender is not stated anywhere in it - the address is + recovered from the signature. A signature that does not verify does not name the wrong sender; + it makes the transaction invalid. + {{ else }} + A frame transaction names its sender outright rather than recovering it, so these are not what + the sender is derived from. They are authorisations the protocol checks before any frame runs, + which is how an account other than the sender can agree to be charged. + {{ end }} +
+ +
+ + + + + + + + + + + + {{ range $idx, $sig := .Signatures }} + + + + + + + + + + + {{ end }} + +
#SchemeSignerAuthorisesVerify gas
{{ $sig.Index }}{{ $sig.SchemeName }} + {{ if $sig.HasSigner }} + {{ formatEthAddressShortLink $sig.SignerAddr false 5 }} + {{ else }} + none + {{ end }} + + {{ $sig.Role }} + {{ if gt (len $sig.Msg) 0 }} + explicit digest + {{ end }} + + {{ formatAddCommas $sig.VerificationGas }} + +
+
+ + + {{ range $part := $sig.Parts }} + + + + + {{ end }} + + + + + +
{{ $part.Name }} + {{ formatHexBytes $part.Value }} + {{ if $part.Note }}— {{ $part.Note }}{{ end }} + +
Signs + {{ if gt (len $sig.Msg) 0 }} + {{ formatHexBytes $sig.Msg }} — an explicit digest + {{ else if $.SignaturesRecoverSender }} + the transaction's signing hash + {{ else }} + the transaction's canonical signature hash + {{ end }} +
+ + {{ if gt (len $sig.Signature) 0 }} +
+ Raw ({{ len $sig.Signature }} bytes{{ if not $sig.Parts }}, no shape this scheme defines{{ end }}): + +
+
{{ formatHexBytes $sig.Signature }}
+ {{ end }} +
+
+
+ + + {{ end }} +
+
+{{ end }} diff --git a/templates/transaction/statechanges.html b/templates/transaction/statechanges.html index 71c84be55..05c3ed6a7 100644 --- a/templates/transaction/statechanges.html +++ b/templates/transaction/statechanges.html @@ -18,6 +18,13 @@ white-space: nowrap; vertical-align: bottom; } + /* One gap rule for the whole header row, so the address, what the account was + and what changed about it are spaced the same way. */ + .sc-acc .accordion-button { gap: .4rem; } + /* The chevron claims the free space by default, which would leave the change + badges floating in the middle rather than against the right edge. */ + .sc-acc .accordion-button::after { margin-left: .75rem; } + .sc-changes { display: inline-flex; align-items: center; gap: .25rem; } {{ if .StateChangesNotAvailable }}
@@ -29,25 +36,30 @@
{{ else }}
-
+
{{ range $idx, $a := .StateChanges }}

diff --git a/templates/transaction/transaction.html b/templates/transaction/transaction.html index 7a576c85c..e3211b490 100644 --- a/templates/transaction/transaction.html +++ b/templates/transaction/transaction.html @@ -80,8 +80,10 @@
Overview
Status:
- {{ if .Status }} - {{ .StatusText }} + {{ if .FrameIncomplete }} + {{ .StatusText }} + {{ else if .Status }} + {{ .StatusText }} {{ else }} {{ .StatusText }} {{ if .RevertReason }}{{ .RevertReason }}{{ end }} @@ -101,20 +103,41 @@
Overview
{{ if gt (len .FromAddr) 0 }} {{ $fromAddr := formatEthAddressFull .FromAddr }} {{ if .FromIsContract }}{{ end }} - {{ $fromAddr }} + {{ formatEthAddressFullLink .FromAddr }} {{ else }} Unknown {{ end }}
+ {{ if and .IsFrameTx (gt (len .PayerAddr) 0) (not .PayerIsSender) }}
-
{{ if .IsCreate }}Created Contract:{{ else }}To:{{ end }}
+
Payer:
- {{ if .HasTo }} + {{ $payerAddr := formatEthAddressFull .PayerAddr }} + {{ formatEthAddressFullLink .PayerAddr }} + +
+
+ {{ end }} +
+
{{ if .IsCreate }}Created Contract:{{ else if .IsFrameTx }}Targets:{{ else }}To:{{ end }}
+
+ {{ if .IsFrameTx }} + {{ if gt .FrameCount 0 }} + + {{ .FrameCount }} frame{{ if ne .FrameCount 1 }}s{{ end }} + + {{ if .FrameShape }}{{ .FrameShape }}{{ end }} + {{ else }} + + multiple targets, no longer retained + + {{ end }} + {{ else if .HasTo }} {{ $toAddr := formatEthAddressFull .ToAddr }} {{ if .ToIsContract }}{{ end }} - {{ $toAddr }} + {{ formatEthAddressFullLink .ToAddr }} {{ else if .IsCreate }} {{ formatContractCreationLink .FromAddr .Nonce }} @@ -144,6 +167,13 @@
Overview
Details + {{ if .IsFrameTx }} + + {{ end }} {{ if .HasTrace }} {{ end }} - {{ if ne (bitwiseAnd (int .DataStatus) 4) 0 }} + {{ if .HasStateChanges }} {{ end }} + {{ if gt (len .Signatures) 0 }} + + {{ end }}
@@ -246,7 +283,14 @@
Overview
Nonce:
-
{{ formatAddCommas .Nonce }}
+
+ {{ formatAddCommas .Nonce }} + {{ if and .IsFrameTx (not .NonceIsAccount) }} + + in nonce key{{ if ne (len .NonceKeys) 1 }}s{{ end }} {{ range $i, $key := .NonceKeys }}{{ if lt $i 4 }}{{ if $i }}, {{ end }}{{ $key.Short }}{{ end }}{{ end }}{{ if gt (len .NonceKeys) 4 }} and {{ sub (len .NonceKeys) 4 }} more{{ end }} + + {{ end }} +
Position in Block:
@@ -267,7 +311,7 @@
Overview
{{ range $i, $entry := .AccessListEntries }}
- {{ formatEthAddressFull $entry.Address }} + {{ formatEthAddressFullLink $entry.Address }} {{ if $entry.StorageKeys }}
    {{ range $entry.StorageKeys }} @@ -352,54 +396,58 @@
    Overview
- {{ if gt .InternalTxCount 0 }} +
+ {{ if eq .TabView "frames" }} + {{ template "frames" . }} + {{ end }} +
+
{{ if eq .TabView "internaltxs" }} {{ template "internaltxs" . }} {{ end }}
- {{ end }} - {{ if gt .EventCount 0 }}
{{ if eq .TabView "events" }} {{ template "events" . }} {{ end }}
- {{ end }} - {{ if gt .TokenTransferCount 0 }}
{{ if eq .TabView "transfers" }} {{ template "transfers" . }} {{ end }}
- {{ end }} - {{ if ne (bitwiseAnd (int .DataStatus) 4) 0 }}
{{ if eq .TabView "statechanges" }} {{ template "statechanges" . }} {{ end }}
- {{ end }} - {{ if gt (len .Authorizations) 0 }}
{{ if eq .TabView "authorizations" }} {{ template "authorizations" . }} {{ end }}
- {{ end }} - {{ if gt .BlobCount 0 }}
{{ if eq .TabView "blobs" }} {{ template "blobs" . }} {{ end }}
- {{ end }} + +
+ {{ if eq .TabView "signatures" }} + {{ template "signatures" . }} + {{ end }} +
{{ end }} {{ define "lazyPage" }} - {{ if eq .TabView "events" }} + {{ if eq .TabView "frames" }} + {{ template "frames" . }} + {{ else if eq .TabView "signatures" }} + {{ template "signatures" . }} + {{ else if eq .TabView "events" }} {{ template "events" . }} {{ else if eq .TabView "statechanges" }} {{ template "statechanges" . }} @@ -512,6 +560,8 @@
Overview
// Map tab IDs to view parameter values var tabToView = { 'details': 'overview', + 'frames': 'frames', + 'signatures': 'signatures', 'events': 'events', 'statechanges': 'statechanges', 'transfers': 'transfers', diff --git a/templates/transaction/transfers.html b/templates/transaction/transfers.html index 93463eafe..862e1c042 100644 --- a/templates/transaction/transfers.html +++ b/templates/transaction/transfers.html @@ -6,6 +6,7 @@ + {{ if $.IsFrameTx }}{{ end }} @@ -17,6 +18,15 @@ {{ range .TokenTransfers }} + {{ if $.IsFrameTx }} + + {{ end }} diff --git a/templates/transaction_frames_template_test.go b/templates/transaction_frames_template_test.go new file mode 100644 index 000000000..1bbbf4c35 --- /dev/null +++ b/templates/transaction_frames_template_test.go @@ -0,0 +1,193 @@ +package templates + +import ( + "bytes" + "testing" + "text/template" + "time" + + "github.com/ethpandaops/dora/types" + "github.com/ethpandaops/dora/types/models" + "github.com/ethpandaops/dora/utils" +) + +// frameTemplate parses the frames table on its own so a bad expression in it shows up +// here rather than on a rendered transaction page. +func frameTemplate(t *testing.T) *template.Template { + t.Helper() + + // Address formatting reads the config to decide whether to link locally, and the + // template renders addresses. + if utils.Config == nil { + utils.Config = &types.Config{} + utils.Config.ExecutionIndexer.Enabled = true + } + + body, err := Files.ReadFile("transaction/frames.html") + if err != nil { + t.Fatalf("read: %v", err) + } + + tmpl, err := template.New("t").Funcs(template.FuncMap(templateFuncs)).Parse(string(body)) + if err != nil { + t.Fatalf("parse: %v", err) + } + + return tmpl +} + +func renderFrames(t *testing.T, data *models.TransactionPageData) string { + t.Helper() + + var out bytes.Buffer + if err := frameTemplate(t).ExecuteTemplate(&out, "frames", data); err != nil { + t.Fatalf("execute: %v", err) + } + + return out.String() +} + +// The four per-frame statuses have to be told apart: a skipped frame is not a failure, +// and a frame whose atomic batch rolled back is not a success even though it reports one. +func TestFramesTemplateDistinguishesStatuses(t *testing.T) { + data := &models.TransactionPageData{ + IsFrameTx: true, + FrameCount: 4, + Frames: []*models.TransactionPageDataFrame{ + {Index: 0, Species: "Expiry check", Status: 1, StatusText: "Success", HasTarget: true, TargetAddr: []byte{0x81, 0x41}, BatchIndex: 0, BatchSize: 1}, + {Index: 1, Species: "User operation", Status: 0, StatusText: "Failed", HasTarget: true, TargetAddr: []byte{0x30, 0x59}, BatchIndex: 1, BatchSize: 1}, + {Index: 2, Species: "User operation", Status: 2, StatusText: "Skipped", HasTarget: true, TargetAddr: []byte{0x30, 0x59}, BatchIndex: 2, BatchSize: 1}, + {Index: 3, Species: "User operation", Status: 255, StatusText: "Unknown", HasTarget: true, TargetAddr: []byte{0x30, 0x59}, BatchIndex: 3, BatchSize: 1}, + }, + } + + got := renderFrames(t, data) + + for _, want := range []string{"Success", "Failed", "Skipped", "Unknown", "text-bg-success", "text-bg-danger", "text-bg-secondary"} { + if !bytes.Contains([]byte(got), []byte(want)) { + t.Errorf("rendered output missing %q", want) + } + } +} + +// A frame that ran inside a batch that later rolled back reports success, but nothing it +// did survived. It must not render as a plain success. +func TestFramesTemplateMarksRolledBackFrames(t *testing.T) { + data := &models.TransactionPageData{ + IsFrameTx: true, + FrameCount: 2, + Frames: []*models.TransactionPageDataFrame{ + {Index: 0, Status: 1, StatusText: "Rolled back", RolledBack: true, BatchFailedIndex: 1, AtomicBatch: true, BatchIndex: 0, BatchSize: 2, IsBatchStart: true, HasTarget: true, TargetAddr: []byte{0x01}}, + {Index: 1, Status: 0, StatusText: "Failed", RolledBack: true, BatchFailedIndex: 1, BatchIndex: 0, BatchSize: 2, IsBatchEnd: true, HasTarget: true, TargetAddr: []byte{0x02}}, + }, + } + + got := renderFrames(t, data) + + if !bytes.Contains([]byte(got), []byte("Rolled back")) { + t.Error("a rolled-back frame must say so") + } + + if !bytes.Contains([]byte(got), []byte("text-bg-warning")) { + t.Error("a rolled-back frame must not be styled as a plain success") + } + + // The batch is bracketed once, on its first frame, and names what undid it. + if n := bytes.Count([]byte(got), []byte(`class="frm-batch-head"`)); n != 1 { + t.Errorf("atomic batch marked %d times, want once", n) + } + + if !bytes.Contains([]byte(got), []byte("rolled back by frame #1")) { + t.Error("the batch must name the frame whose failure undid it") + } + + // An opened batch has to be closed, or every frame after it renders inside it. + if open, close := bytes.Count([]byte(got), []byte(`
`)), bytes.Count([]byte(got), []byte("
")); open == 0 || close < open { + t.Errorf("batch wrapper opened %d times, closing tags %d", open, close) + } +} + +// The expiry deadline is one of the more useful things to show, and it comes from the +// envelope rather than any row, so it is absent whenever the block has been pruned. It is +// stated against the time the transaction was included, because that is when the frame +// checked it - counting down to it from now would call a transaction that was never late +// expired. +func TestFramesTemplateShowsExpiryOnlyWhenKnown(t *testing.T) { + frames := []*models.TransactionPageDataFrame{ + {Index: 0, Species: "Expiry check", Status: 1, StatusText: "Success", HasTarget: true, TargetAddr: []byte{0x81, 0x41}, BatchSize: 1}, + } + + withExpiry := renderFrames(t, &models.TransactionPageData{ + IsFrameTx: true, FrameCount: 1, Frames: frames, + HasExpiry: true, ExpiryTime: time.Now().Add(10 * time.Minute), ExpiryMargin: "10 min.", + }) + if !bytes.Contains([]byte(withExpiry), []byte("included 10 min. before its deadline")) { + t.Error("a known deadline must be shown against the inclusion time") + } + + // A deadline already gone by inclusion is something a conforming client would not + // have included, so it is called out rather than shown as ordinary slack. + alreadyPassed := renderFrames(t, &models.TransactionPageData{ + IsFrameTx: true, FrameCount: 1, Frames: frames, + HasExpiry: true, ExpiryTime: time.Now(), ExpiryMargin: "2 min.", ExpiryPassed: true, + }) + if !bytes.Contains([]byte(alreadyPassed), []byte("deadline had passed 2 min. before inclusion")) { + t.Error("a deadline already gone at inclusion must say so") + } + + withoutExpiry := renderFrames(t, &models.TransactionPageData{ + IsFrameTx: true, FrameCount: 1, Frames: frames, + }) + if bytes.Contains([]byte(withoutExpiry), []byte("deadline")) { + t.Error("no deadline must be claimed when none is known") + } +} + +// A frame that declares no target addresses the sender, which is worth saying so the +// address does not look like an arbitrary recipient. +func TestFramesTemplateMarksSelfAddressedFrames(t *testing.T) { + got := renderFrames(t, &models.TransactionPageData{ + IsFrameTx: true, + FrameCount: 1, + Frames: []*models.TransactionPageDataFrame{ + {Index: 0, Species: "Self verify", Status: 1, StatusText: "Success", HasTarget: true, TargetIsSender: true, TargetAddr: []byte{0x6d, 0xf3}, BatchSize: 1}, + }, + }) + + if !bytes.Contains([]byte(got), []byte("(sender)")) { + t.Error("a self-addressed frame must be marked as such") + } +} + +// The frames table is included from the details tab, so both files have to parse +// together: a reference to a template that is not registered only fails at parse time of +// the whole set, not of either file alone. +func TestTransactionTemplateSetParses(t *testing.T) { + files := []string{ + "transaction/transaction.html", + "transaction/events.html", + "transaction/statechanges.html", + "transaction/transfers.html", + "transaction/internaltxs.html", + "transaction/authorizations.html", + "transaction/blobs.html", + "transaction/frames.html", + } + + tmpl := template.New("t").Funcs(template.FuncMap(templateFuncs)) + + for _, name := range files { + body, err := Files.ReadFile(name) + if err != nil { + t.Fatalf("read %s: %v", name, err) + } + + if _, err := tmpl.Parse(string(body)); err != nil { + t.Fatalf("parse %s: %v", name, err) + } + } + + if tmpl.Lookup("frames") == nil { + t.Error(`the details tab includes {{ template "frames" }}, which is not defined`) + } +} diff --git a/templates/transactions/transactions.html b/templates/transactions/transactions.html index c4e57be86..dfa9ec51a 100644 --- a/templates/transactions/transactions.html +++ b/templates/transactions/transactions.html @@ -84,6 +84,7 @@

Execution Transactions

+ @@ -189,6 +190,8 @@

Execution Transactions

{{ if .HasTo }} {{ formatEthAddressShortLink .ToAddr .ToIsContract }} + {{ else if .IsMultiTarget }} + multiple targets {{ else }} {{ formatContractCreationLink .FromAddr .Nonce }} {{ end }} @@ -196,17 +199,21 @@

Execution Transactions

{{ if $.DisplayValue }}{{ end }} {{ if $.DisplayFee }}{{ end }} {{ if $.DisplayGasUsed }}{{ end }} - {{ if $.DisplayType }}{{ end }} + {{ if $.DisplayType }}{{ end }} {{ if $.DisplayNonce }}{{ end }}
FrameToken Address Token Name From
+ {{ if .HasFrame }} + frame #{{ .FrameIndex }} + {{ else }} + - + {{ end }} + {{ formatEthAddressShortLink .Contract true }} {{ formatTransactionValue .Amount }}{{ formatTransactionFee .TxFee }}{{ formatAddCommas .GasUsed }}{{ if eq .TxType 0 }}Legacy{{ else if eq .TxType 1 }}AccessList{{ else if eq .TxType 2 }}DynFee{{ else if eq .TxType 3 }}Blob{{ else if eq .TxType 4 }}SetCode{{ else }}{{ .TxType }}{{ end }}{{ if eq .TxType 0 }}Legacy{{ else if eq .TxType 1 }}AccessList{{ else if eq .TxType 2 }}DynFee{{ else if eq .TxType 3 }}Blob{{ else if eq .TxType 4 }}SetCode{{ else if eq .TxType 6 }}Frame{{ else }}{{ .TxType }}{{ end }}{{ .Nonce }} {{ if .BlockOrphaned }} - {{ if .Reverted }} + {{ if and .Reverted .IsMultiTarget }} + Orphaned + {{ else if .Reverted }} Orphaned {{ else }} Orphaned {{ end }} {{ else }} - {{ if .Reverted }} + {{ if and .Reverted .IsMultiTarget }} + Complete + {{ else if .Reverted }} Reverted {{ else }} Success diff --git a/templates/validator/validator.html b/templates/validator/validator.html index 5f64356a3..bae62d272 100644 --- a/templates/validator/validator.html +++ b/templates/validator/validator.html @@ -324,7 +324,7 @@

Validator {{ forma }); } - var tab = new bootstrap.Tab(tabEl); + var tab = new bootstrap.Tab(link); tab.show(); window.history.replaceState(null, document.title, "/validator/{{ .Index }}" + link.getAttribute('href')); diff --git a/templates/withdrawals/withdrawals.html b/templates/withdrawals/withdrawals.html index 66b131ca6..ad95d1333 100644 --- a/templates/withdrawals/withdrawals.html +++ b/templates/withdrawals/withdrawals.html @@ -494,7 +494,7 @@

This table displays the most recent beacon chain with }); } - var tab = new bootstrap.Tab(tabEl); + var tab = new bootstrap.Tab(link); tab.show(); window.history.replaceState(null, document.title, "/validators/withdrawals" + link.getAttribute('href')); diff --git a/types/models/address.go b/types/models/address.go index 31da84dac..941643cae 100644 --- a/types/models/address.go +++ b/types/models/address.go @@ -104,15 +104,18 @@ type AddressPageDataTransaction struct { ToID uint64 `json:"to_id"` ToIsContract bool `json:"to_is_contract"` HasTo bool `json:"has_to"` // false for contract creation - IsOutgoing bool `json:"is_outgoing"` - Nonce uint64 `json:"nonce"` - Amount float64 `json:"amount"` - AmountRaw []byte `json:"amount_raw"` - TxFee float64 `json:"tx_fee"` // Transaction fee in ETH - Reverted bool `json:"reverted"` - RevertReason string `json:"revert_reason,omitempty"` - MethodID []byte `json:"method_id"` - MethodName string `json:"method_name"` + // IsMultiTarget marks a transaction that addresses several recipients rather than + // one, so a missing recipient is not a contract creation. + IsMultiTarget bool `json:"is_multi_target"` + IsOutgoing bool `json:"is_outgoing"` + Nonce uint64 `json:"nonce"` + Amount float64 `json:"amount"` + AmountRaw []byte `json:"amount_raw"` + TxFee float64 `json:"tx_fee"` // Transaction fee in ETH + Reverted bool `json:"reverted"` + RevertReason string `json:"revert_reason,omitempty"` + MethodID []byte `json:"method_id"` + MethodName string `json:"method_name"` } // AddressPageDataTokenTransfer represents a token transfer diff --git a/types/models/search.go b/types/models/search.go index 84ab2c8cd..df4154f52 100644 --- a/types/models/search.go +++ b/types/models/search.go @@ -76,4 +76,7 @@ type SearchAheadTransactionResult struct { TxHash string `json:"tx_hash,omitempty"` BlockNumber uint64 `json:"block_number,omitempty"` Reverted bool `json:"reverted,omitempty"` + // FrameIncomplete marks a frame transaction not every frame of which succeeded; the + // transaction itself ran and paid, so it is not reverted. + FrameIncomplete bool `json:"frame_incomplete,omitempty"` } diff --git a/types/models/slot.go b/types/models/slot.go index b6904a2ab..aabd38a13 100644 --- a/types/models/slot.go +++ b/types/models/slot.go @@ -339,6 +339,12 @@ type SlotPageTransaction struct { Type uint64 `json:"type"` TypeName string `json:"type_name"` + // IsMultiTarget marks a transaction that addresses several recipients rather than + // one - an EIP-8141 frame transaction - so its missing recipient is not a contract + // creation. FrameCount is how many calls it carries. + IsMultiTarget bool `json:"is_multi_target"` + FrameCount uint64 `json:"frame_count"` + // EL-enriched data (only available when execution indexer is enabled) HasElData bool `json:"has_el_data"` Reverted bool `json:"reverted"` diff --git a/types/models/transaction.go b/types/models/transaction.go index edce47d41..6b2e54803 100644 --- a/types/models/transaction.go +++ b/types/models/transaction.go @@ -143,6 +143,95 @@ type TransactionPageData struct { // EIP-7976: calldata floor gas cost = 21000 + 64 × len(calldata); 0 if no calldata CalldataFloorGas uint64 `json:"calldata_floor_gas"` + // Frame transaction (EIP-8141). A frame transaction is an ordered list of calls + // rather than one, so it has no recipient, value or status of its own. + IsFrameTx bool `json:"is_frame_tx"` + FrameCount uint64 `json:"frame_count"` + + // FrameResultsMissing marks frames shown as declared but not as executed. What each + // frame did is only on the receipt, which is kept for blocks indexed with execution + // details - so a deployment that indexes without them, or a block that predates the + // receipt being stored, leaves the frames without results. + FrameResultsMissing bool `json:"frame_results_missing"` + + // FrameExtensions names which of EIP-8141's extensions the payload used - EIP-8250's + // keyed nonces and EIP-8272's recent roots are independent, so four shapes exist and + // the transaction says which one it is. FrameHasKeyedNonces is the structural + // question, as against NonceIsAccount, which asks whether the sequence is the + // sender's account nonce. + FrameExtensions string `json:"frame_extensions"` + FrameHasKeyedNonces bool `json:"frame_has_keyed_nonces"` + + // FrameBodyReverted marks a transaction whose POST_TX frame failed, which reverts + // everything after the validation prefix rather than only its own atomic batch. + FrameBodyReverted bool `json:"frame_body_reverted"` + + // FrameShape names the transaction by its validation prefix - the thing that makes a + // frame transaction legible at a glance. + FrameShape string `json:"frame_shape"` + + // FrameValidationCount is how many leading frames form the validation prefix: the + // run whose success settles whether the transaction runs and who pays for it. The + // frames after it carry out the sender's operations. + FrameValidationCount int `json:"frame_validation_count"` + + // Totals over the frames, which is where a frame transaction's gas actually goes. + FrameExecGasUsed uint64 `json:"frame_exec_gas_used"` + FrameStateGasUsed uint64 `json:"frame_state_gas_used"` + FrameSuccessCount int `json:"frame_success_count"` + FrameFailedCount int `json:"frame_failed_count"` + FrameSkippedCount int `json:"frame_skipped_count"` + + // FrameFailedIndex is the first frame that failed, meaningful only when + // FrameFailedCount is non-zero. A frame transaction has no revert reason of its own: + // it did not revert, one of its frames did. + FrameFailedIndex uint32 `json:"frame_failed_index"` + FrameRolledBackCnt int `json:"frame_rolled_back_count"` + + // FrameIncomplete marks a frame transaction that ran and paid but did not carry out + // everything it asked for. It is not a revert: what the other frames did stands. + // FrameStatusDetail says which frames did not, for the status tooltip. + FrameIncomplete bool `json:"frame_incomplete"` + FrameStatusDetail string `json:"frame_status_detail"` + + // PayerAddr settled the fee. Worth showing whenever it is not the sender, which is + // the whole point of a sponsored transaction. + PayerAddr []byte `json:"payer_addr"` + PayerIsSender bool `json:"payer_is_sender"` + + // FeeRecipientAddr was paid the block's transaction fees. + FeeRecipientAddr []byte `json:"fee_recipient_addr"` + + // ExpiryTime is the deadline an expiry verifier frame checked against. + // + // The frame checked it when the transaction executed, so on an included transaction + // the deadline only says how much room it had left. ExpiryMargin is that distance + // from the inclusion time, and ExpiryPassed marks the deadline as already gone by + // then - which a conforming client would not have included. + HasExpiry bool `json:"has_expiry"` + ExpiryTime time.Time `json:"expiry_time"` + ExpiryMargin string `json:"expiry_margin"` + ExpiryPassed bool `json:"expiry_passed"` + + // NonceIsAccount reports whether NonceSeq is the sender's ordinary account nonce, + // which is only so when the transaction names the zero nonce key alone. Otherwise it + // is sequenced in a domain of its own and NonceKeys names which. + NonceIsAccount bool `json:"nonce_is_account"` + NonceKeys []*TransactionPageDataNonceKey `json:"nonce_keys"` + + Frames []*TransactionPageDataFrame `json:"frames"` + + // Signatures is what authenticated the transaction: the one ECDSA signature an + // ordinary transaction is signed with, or the list a frame transaction carries. It + // lives in the envelope, so it is present exactly while the block is. + Signatures []*TransactionPageDataSignature `json:"signatures"` + + // SignaturesRecoverSender reports whether the sender is recovered from the signature, + // as it is for every type but a frame transaction, which names its sender outright. + SignaturesRecoverSender bool `json:"signatures_recover_sender"` + + FrameRecentRoots []*TransactionPageDataFrameRecentRoot `json:"frame_recent_roots"` + // Tab view TabView string `json:"tab_view"` @@ -155,13 +244,19 @@ type TransactionPageData struct { TokenTransferCount uint64 `json:"token_transfer_count"` // Internal transactions tab - HasTrace bool `json:"has_trace"` // a call trace exists (>=1 frame) + HasTrace bool `json:"has_trace"` // a call trace exists (>=1 frame) + HasStateChanges bool `json:"has_state_changes"` // a state diff was stored for this block InternalTxs []*TransactionPageDataInternalTx `json:"internal_txs"` InternalTxCount uint64 `json:"internal_tx_count"` DataStatus uint16 `json:"data_status"` // blockdb data availability flags EventsNotAvailable bool `json:"events_not_available"` InternalTxsNotAvailable bool `json:"internal_txs_not_available"` - InternalTxIndentPx float64 `json:"internal_tx_indent_px"` + + // FrameCallsNotTraced marks a frame transaction whose client did not decompose it + // into its frames. The block stored call traces, this transaction just has none that + // say anything about it, which is a different thing from the data being gone. + FrameCallsNotTraced bool `json:"frame_calls_not_traced"` + InternalTxIndentPx float64 `json:"internal_tx_indent_px"` // State changes tab (prestateTracer diffMode) StateChanges []*TransactionPageDataStateChangeAccount `json:"state_changes"` @@ -170,6 +265,150 @@ type TransactionPageData struct { EnsNameData } +// TransactionPageDataSignature is a signature that authenticated the transaction. +// +// Every type but a frame transaction carries exactly one, and the sender is recovered +// from it. A frame transaction names its sender outright and carries a list instead: a +// set of authorisations the protocol checks before any frame runs, which is how an +// account other than the sender agrees to pay. +type TransactionPageDataSignature struct { + Index uint32 `json:"index"` + + Scheme uint8 `json:"scheme"` + SchemeName string `json:"scheme_name"` + + // SignerAddr is the account the entry authorises for. An entry that names none + // authorises for the sender; an arbitrary witness authorises for nobody and carries + // no signer at all. + SignerAddr []byte `json:"signer_addr"` + HasSigner bool `json:"has_signer"` + SignerIsSender bool `json:"signer_is_sender"` + + // Role names what the entry does in this transaction, where that can be told from + // the accounts it names. + Role string `json:"role"` + + // Msg is an explicit digest when the entry signs one rather than the transaction's + // canonical signature hash. + Msg []byte `json:"msg"` + + Signature []byte `json:"signature"` + VerificationGas uint64 `json:"verification_gas"` + + // Parts are the signature's raw bytes split into the fields its scheme defines, or + // nil when the bytes are not the length that scheme expects. + Parts []*TransactionPageDataSignaturePart `json:"parts"` +} + +// TransactionPageDataSignaturePart is one named field of a signature entry, decoded +// according to the entry's scheme. +type TransactionPageDataSignaturePart struct { + Name string `json:"name"` + Value []byte `json:"value"` + + // Note carries anything about the field worth saying beside it. + Note string `json:"note"` +} + +// TransactionPageDataNonceKey is one EIP-8250 nonce key the transaction selects. A key is +// an opaque 256-bit identifier rather than a quantity - applications derive them from +// things like nullifiers - so it is carried as hex, with a short form for inline use. +type TransactionPageDataNonceKey struct { + Index uint32 `json:"index"` + Key string `json:"key"` + Short string `json:"short"` +} + +// TransactionPageDataFrameRecentRoot is an EIP-8272 recent root the transaction declared, +// so that a frame can read it while the transaction executes. +type TransactionPageDataFrameRecentRoot struct { + Index uint32 `json:"index"` + SourceID []byte `json:"source_id"` + Slot uint64 `json:"slot"` + Root []byte `json:"root"` +} + +// TransactionPageDataFrame is one frame of an EIP-8141 frame transaction. +type TransactionPageDataFrame struct { + Index uint32 `json:"index"` + + Mode uint8 `json:"mode"` + ModeName string `json:"mode_name"` + + // Species names what the frame does within the transaction - a deadline check, a + // paymaster approving payment, the user's own operation - and SpeciesInfo says what + // that kind of frame is for. + Species string `json:"species"` + SpeciesInfo string `json:"species_info"` + + Flags uint8 `json:"flags"` + ApprovesPayment bool `json:"approves_payment"` + ApprovesExecution bool `json:"approves_execution"` + + // AtomicBatch marks a frame batched with the frame after it. BatchIndex groups the + // frames of one batch so they can be shown together; frames outside a batch each get + // their own. + AtomicBatch bool `json:"atomic_batch"` + BatchIndex int `json:"batch_index"` + BatchSize int `json:"batch_size"` + IsBatchStart bool `json:"is_batch_start"` + IsBatchEnd bool `json:"is_batch_end"` + + // IsValidation marks a frame in the validation prefix. Those frames decide whether + // the transaction runs at all; the ones after them do the work it was sent for. + IsValidation bool `json:"is_validation"` + + // CallerAddr is where the frame's call comes from. DEFAULT and VERIFY frames are + // entered by the ENTRY_POINT predeploy rather than by the sender, which is what lets + // a frame run against an account without that account authorising it directly. + CallerAddr []byte `json:"caller_addr"` + CallerIsSender bool `json:"caller_is_sender"` + CallerLabel string `json:"caller_label"` + + TargetAddr []byte `json:"target_addr"` + TargetIsSender bool `json:"target_is_sender"` + HasTarget bool `json:"has_target"` + + // TargetLabel names a target that is a protocol predeploy rather than an account. + TargetLabel string `json:"target_label"` + + Amount float64 `json:"amount"` + DataLen uint32 `json:"data_len"` + MethodID []byte `json:"method_id"` + + // Data is the frame's calldata, which the transaction carries and the receipt does + // not - so it is present exactly when the transaction's block still is. + Data []byte `json:"data"` + MethodName string `json:"method_name"` + MethodSignature string `json:"method_signature"` + DecodedCalldata []*utils.DecodedCalldataParam `json:"decoded_calldata"` + + // Status is EIP-8141's per-frame status. Skipped is neither a success nor a failure: + // an earlier frame in the same atomic batch failed and this one never ran. Unknown + // means the client reported no result for it. + Status uint8 `json:"status"` + StatusText string `json:"status_text"` + + // RolledBack marks a frame whose atomic batch was undone after it ran. It may report + // success, but its logs were discarded and its state gas zeroed - nothing it did + // survived, and a plain success would say otherwise. BatchFailedIndex names the + // frame that failed and took the batch with it. + RolledBack bool `json:"rolled_back"` + BatchFailedIndex int `json:"batch_failed_index"` + + // EIP-8037 budgets and reports each frame in two gas dimensions. + ExecGasLimit uint64 `json:"exec_gas_limit"` + StateGasLimit uint64 `json:"state_gas_limit"` + ExecGasUsed uint64 `json:"exec_gas_used"` + StateGasUsed uint64 `json:"state_gas_used"` + + LogCount uint16 `json:"log_count"` + + // ExpiryTime is set on an expiry verifier frame, whose calldata is the deadline. + HasExpiry bool `json:"has_expiry"` + ExpiryTime time.Time `json:"expiry_time"` +} + // TransactionAccessListEntry is one address+storage-keys pair from an EIP-2930 access list. type TransactionAccessListEntry struct { Address []byte `json:"address"` @@ -180,6 +419,19 @@ type TransactionAccessListEntry struct { type TransactionPageDataStateChangeAccount struct { Address []byte `json:"address" ssz-size:"20"` + // The part the account played in the transaction, where it played one. Balances move + // for reasons that are not visible from the numbers alone: the sender's for what it + // spent, the fee recipient's for what it was paid, and a frame transaction's payer + // for a fee its sender did not owe. + IsSender bool `json:"is_sender"` + IsPayer bool `json:"is_payer"` + IsFeeRecipient bool `json:"is_fee_recipient"` + + // PredeployName names the protocol's own account, where the account is one. Storage + // on NONCE_MANAGER or RECENT_ROOTS is written by the protocol rather than by any + // frame, and says nothing without the name. + PredeployName string `json:"predeploy_name"` + // High level flags (precomputed from the binary flags) AccountCreated bool `json:"account_created"` AccountKilled bool `json:"account_killed"` @@ -217,6 +469,12 @@ type TransactionPageDataStateChangeSlot struct { type TransactionPageDataEvent struct { EventIndex uint32 `json:"event_index"` + // FrameIndex is the frame of a frame transaction that emitted the event. A + // transaction's logs are the per-frame lists concatenated in frame order, so the + // per-frame counts say which frame each one came from. + FrameIndex uint32 `json:"frame_index"` + HasFrame bool `json:"has_frame"` + // Source contract SourceAddr []byte `json:"source_addr" ssz-size:"20"` SourceIsContract bool `json:"source_is_contract"` @@ -244,6 +502,14 @@ type TransactionPageDataEvent struct { type TransactionPageDataTokenTransfer struct { TransferIndex uint32 `json:"transfer_index"` + // EventIndex is the transaction log the transfer was decoded from. Several transfers + // can share one - an ERC1155 batch is a single log - so it is not a key. + EventIndex uint32 `json:"event_index"` + + // FrameIndex is the frame of a frame transaction whose log this was. + FrameIndex uint32 `json:"frame_index"` + HasFrame bool `json:"has_frame"` + // Token info TokenID uint64 `json:"token_id"` Contract []byte `json:"contract" ssz-size:"20"` @@ -273,6 +539,12 @@ type TransactionPageDataInternalTx struct { CallType uint8 `json:"call_type"` TypeName string `json:"type_name"` + // FrameIndex is the frame of a frame transaction the call was made from. A client + // that decomposes such a transaction traces one root per executed frame, so every + // call below a root belongs to that frame. + FrameIndex uint32 `json:"frame_index"` + HasFrame bool `json:"has_frame"` + // From/To FromAddr []byte `json:"from_addr" ssz-size:"20"` FromIsContract bool `json:"from_is_contract"` diff --git a/types/models/transactions.go b/types/models/transactions.go index 682b51971..e48c7eff7 100644 --- a/types/models/transactions.go +++ b/types/models/transactions.go @@ -54,6 +54,7 @@ type TransactionsFilter struct { Type2 bool `json:"type2"` Type3 bool `json:"type3"` Type4 bool `json:"type4"` + Type6 bool `json:"type6"` Active bool `json:"active"` // any filter set (controls panel open state) } @@ -69,14 +70,18 @@ type TransactionsPageDataTransaction struct { ToAddr []byte `json:"to_addr"` ToIsContract bool `json:"to_is_contract"` HasTo bool `json:"has_to"` - IsCreate bool `json:"is_create"` - Nonce uint64 `json:"nonce"` - Amount float64 `json:"amount"` - TxFee float64 `json:"tx_fee"` - GasUsed uint64 `json:"gas_used"` - TxType uint8 `json:"tx_type"` - Reverted bool `json:"reverted"` - RevertReason string `json:"revert_reason,omitempty"` - MethodID []byte `json:"method_id"` - MethodName string `json:"method_name"` + // IsMultiTarget marks a transaction that addresses several recipients rather than + // one, so a missing recipient is not a contract creation. + IsMultiTarget bool `json:"is_multi_target"` + FrameCount int `json:"frame_count"` + IsCreate bool `json:"is_create"` + Nonce uint64 `json:"nonce"` + Amount float64 `json:"amount"` + TxFee float64 `json:"tx_fee"` + GasUsed uint64 `json:"gas_used"` + TxType uint8 `json:"tx_type"` + Reverted bool `json:"reverted"` + RevertReason string `json:"revert_reason,omitempty"` + MethodID []byte `json:"method_id"` + MethodName string `json:"method_name"` }