diff --git a/accounts/abi/bind/backends/simulated.go b/accounts/abi/bind/backends/simulated.go index c009c0233..a00c81b65 100644 --- a/accounts/abi/bind/backends/simulated.go +++ b/accounts/abi/bind/backends/simulated.go @@ -918,8 +918,11 @@ func (m callMsg) IsL1MessageTx() bool { return false } func (m callMsg) SetCodeAuthorizations() []types.SetCodeAuthorization { return m.CallMsg.AuthorizationList } -func (m callMsg) FeeTokenID() uint16 { return m.CallMsg.FeeTokenID } -func (m callMsg) FeeLimit() *big.Int { return m.CallMsg.FeeLimit } +func (m callMsg) FeeTokenID() uint16 { return m.CallMsg.FeeTokenID } +func (m callMsg) FeeLimit() *big.Int { return m.CallMsg.FeeLimit } +func (m callMsg) Version() uint8 { return m.CallMsg.Version } +func (m callMsg) Reference() *common.Reference { return m.CallMsg.Reference } +func (m callMsg) Memo() *[]byte { return m.CallMsg.Memo } // filterBackend implements filters.Backend to support filtering for logs without // taking bloom-bits acceleration structures into account. diff --git a/accounts/abi/bind/base.go b/accounts/abi/bind/base.go index 263a7dd8a..2f7426a60 100644 --- a/accounts/abi/bind/base.go +++ b/accounts/abi/bind/base.go @@ -57,8 +57,11 @@ type TransactOpts struct { GasTipCap *big.Int // Gas priority fee cap to use for the 1559 transaction execution (nil = gas price oracle) GasLimit uint64 // Gas limit to set for the transaction execution (0 = estimate) - FeeTokenID uint16 // alt fee token id of transaction execution - FeeLimit *big.Int // alt fee token limit of transaction execution + FeeTokenID uint16 // alt fee token id of transaction execution + FeeLimit *big.Int // alt fee token limit of transaction execution + Version *uint8 // version of morph tx (nil = auto-detect) + Reference *common.Reference // reference key for the transaction (optional) + Memo *[]byte // memo for the transaction (optional) Context context.Context // Network context to support cancellation and timeouts (nil = no timeout) @@ -290,12 +293,19 @@ func (c *BoundContract) createDynamicTx(opts *TransactOpts, contract *common.Add return types.NewTx(baseTx), nil } -func (c *BoundContract) createAltFeeTx(opts *TransactOpts, contract *common.Address, input []byte, head *types.Header) (*types.Transaction, error) { +func (c *BoundContract) createMorphTx(opts *TransactOpts, contract *common.Address, input []byte, head *types.Header) (*types.Transaction, error) { // Normalize value value := opts.Value if value == nil { value = new(big.Int) } + + // Determine version and validate fields + version, err := c.morphTxVersion(opts) + if err != nil { + return nil, err + } + // Estimate TipCap gasTipCap := opts.GasTipCap if gasTipCap == nil { @@ -334,7 +344,7 @@ func (c *BoundContract) createAltFeeTx(opts *TransactOpts, contract *common.Addr if err != nil { return nil, err } - baseTx := &types.AltFeeTx{ + baseTx := &types.MorphTx{ To: contract, Nonce: nonce, GasFeeCap: gasFeeCap, @@ -342,12 +352,65 @@ func (c *BoundContract) createAltFeeTx(opts *TransactOpts, contract *common.Addr FeeTokenID: opts.FeeTokenID, FeeLimit: opts.FeeLimit, Gas: gasLimit, + Version: version, + Reference: opts.Reference, + Memo: opts.Memo, Value: value, Data: input, } return types.NewTx(baseTx), nil } +// morphTxVersion determines the MorphTx version and validates field requirements. +// If version is explicitly specified, validate that parameters match: +// - Version 0: FeeTokenID must be > 0, Reference and Memo must not be set +// - Version 1: FeeTokenID, Reference, Memo are all optional; +// if FeeTokenID is 0, FeeLimit must not be set +// +// If version is not explicitly specified, use heuristic detection: +// - V1 if V1-specific fields (Reference, Memo) are present +// - V0 otherwise (backward compatible with AltFeeTx behavior) +func (c *BoundContract) morphTxVersion(opts *TransactOpts) (uint8, error) { + // Validate memo length + if opts.Memo != nil && len(*opts.Memo) > common.MaxMemoLength { + return 0, types.ErrMemoTooLong + } + + // If version is not explicitly specified, determine based on fields: + // - V1 if V1-specific fields (Reference, Memo) are present + // - V0 otherwise (backward compatible with AltFeeTx behavior) + if opts.Version == nil { + hasV1Fields := (opts.Reference != nil && *opts.Reference != (common.Reference{})) || + (opts.Memo != nil && len(*opts.Memo) > 0) + if hasV1Fields { + if opts.FeeTokenID == 0 && opts.FeeLimit != nil && opts.FeeLimit.Sign() != 0 { + return 0, types.ErrMorphTxV1IllegalExtraParams + } + return types.MorphTxVersion1, nil + } + return types.MorphTxVersion0, nil + } + + // Version explicitly specified - validate parameters match + version := *opts.Version + switch version { + case types.MorphTxVersion0: + if opts.FeeTokenID == 0 || + opts.Reference != nil && *opts.Reference != (common.Reference{}) || + opts.Memo != nil && len(*opts.Memo) > 0 { + return 0, types.ErrMorphTxV0IllegalExtraParams + } + case types.MorphTxVersion1: + if opts.FeeTokenID == 0 && opts.FeeLimit != nil && opts.FeeLimit.Sign() != 0 { + return 0, types.ErrMorphTxV1IllegalExtraParams + } + default: + return 0, types.ErrMorphTxUnsupportedVersion + } + + return version, nil +} + func (c *BoundContract) createLegacyTx(opts *TransactOpts, contract *common.Address, input []byte) (*types.Transaction, error) { if opts.GasFeeCap != nil || opts.GasTipCap != nil { return nil, errors.New("maxFeePerGas or maxPriorityFeePerGas specified but curie is not active yet") @@ -438,8 +501,11 @@ func (c *BoundContract) transact(opts *TransactOpts, contract *common.Address, i if head, errHead := c.transactor.HeaderByNumber(ensureContext(opts.Context), nil); errHead != nil { return nil, errHead } else if head.BaseFee != nil { - if opts.FeeTokenID != 0 { - rawTx, err = c.createAltFeeTx(opts, contract, input, head) + if opts.FeeTokenID != 0 || + opts.Version != nil || + (opts.Reference != nil && *opts.Reference != (common.Reference{})) || + (opts.Memo != nil && len(*opts.Memo) > 0) { + rawTx, err = c.createMorphTx(opts, contract, input, head) } else { rawTx, err = c.createDynamicTx(opts, contract, input, head) } diff --git a/accounts/abi/bind/morph_tx_version_test.go b/accounts/abi/bind/morph_tx_version_test.go new file mode 100644 index 000000000..73c73428a --- /dev/null +++ b/accounts/abi/bind/morph_tx_version_test.go @@ -0,0 +1,183 @@ +package bind + +import ( + "math/big" + "testing" + + "github.com/morph-l2/go-ethereum/common" + "github.com/morph-l2/go-ethereum/core/types" +) + +func versionPtr(v uint8) *uint8 { + return &v +} + +func refTestPtr(r common.Reference) *common.Reference { + return &r +} + +func memoTestPtr(b []byte) *[]byte { + return &b +} + +// TestMorphTxVersion_HeuristicDefault tests the heuristic version defaulting logic +// in morphTxVersion(): +// - Version == nil + no V1 fields → V0 +// - Version == nil + Reference or Memo → V1 +// - Explicit Version → use as-is (with validation) +func TestMorphTxVersion_HeuristicDefault(t *testing.T) { + ref := common.HexToReference("0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + emptyRef := common.Reference{} + memo := []byte("test memo") + emptyMemo := []byte{} + + bc := &BoundContract{} // morphTxVersion doesn't use BoundContract fields + + tests := []struct { + name string + opts *TransactOpts + wantVersion uint8 + wantErr error + }{ + // === Heuristic defaults (Version == nil) === + { + name: "nil Version, FeeTokenID > 0, no V1 fields → V0", + opts: &TransactOpts{FeeTokenID: 1}, + wantVersion: types.MorphTxVersion0, + }, + { + name: "nil Version, FeeTokenID > 0, with Reference → V1", + opts: &TransactOpts{FeeTokenID: 1, Reference: refTestPtr(ref)}, + wantVersion: types.MorphTxVersion1, + }, + { + name: "nil Version, FeeTokenID > 0, with Memo → V1", + opts: &TransactOpts{FeeTokenID: 1, Memo: memoTestPtr(memo)}, + wantVersion: types.MorphTxVersion1, + }, + { + name: "nil Version, FeeTokenID > 0, with Reference + Memo → V1", + opts: &TransactOpts{FeeTokenID: 1, Reference: refTestPtr(ref), Memo: memoTestPtr(memo)}, + wantVersion: types.MorphTxVersion1, + }, + { + name: "nil Version, FeeTokenID = 0, with Reference → V1", + opts: &TransactOpts{FeeTokenID: 0, Reference: refTestPtr(ref)}, + wantVersion: types.MorphTxVersion1, + }, + { + name: "nil Version, FeeTokenID = 0, with Memo → V1", + opts: &TransactOpts{FeeTokenID: 0, Memo: memoTestPtr(memo)}, + wantVersion: types.MorphTxVersion1, + }, + { + name: "nil Version, FeeTokenID = 0, no V1 fields → V0", + opts: &TransactOpts{FeeTokenID: 0}, + wantVersion: types.MorphTxVersion0, + }, + { + name: "nil Version, empty Reference → V0", + opts: &TransactOpts{FeeTokenID: 1, Reference: refTestPtr(emptyRef)}, + wantVersion: types.MorphTxVersion0, + }, + { + name: "nil Version, empty Memo → V0", + opts: &TransactOpts{FeeTokenID: 1, Memo: memoTestPtr(emptyMemo)}, + wantVersion: types.MorphTxVersion0, + }, + { + name: "nil Version, nil Reference, nil Memo → V0", + opts: &TransactOpts{FeeTokenID: 1, Reference: nil, Memo: nil}, + wantVersion: types.MorphTxVersion0, + }, + + // === V1 heuristic with illegal params === + { + name: "nil Version, Reference + FeeTokenID=0 + FeeLimit > 0 → error", + opts: &TransactOpts{FeeTokenID: 0, FeeLimit: big.NewInt(100), Reference: refTestPtr(ref)}, + wantErr: types.ErrMorphTxV1IllegalExtraParams, + }, + { + name: "nil Version, Reference + FeeTokenID=0 + FeeLimit=0 → V1 (ok)", + opts: &TransactOpts{FeeTokenID: 0, FeeLimit: big.NewInt(0), Reference: refTestPtr(ref)}, + wantVersion: types.MorphTxVersion1, + }, + { + name: "nil Version, Reference + FeeTokenID=0 + nil FeeLimit → V1 (ok)", + opts: &TransactOpts{FeeTokenID: 0, FeeLimit: nil, Reference: refTestPtr(ref)}, + wantVersion: types.MorphTxVersion1, + }, + + // === Explicit Version === + { + name: "explicit V0, FeeTokenID > 0 → V0", + opts: &TransactOpts{Version: versionPtr(types.MorphTxVersion0), FeeTokenID: 1}, + wantVersion: types.MorphTxVersion0, + }, + { + name: "explicit V1, no special fields → V1", + opts: &TransactOpts{Version: versionPtr(types.MorphTxVersion1)}, + wantVersion: types.MorphTxVersion1, + }, + { + name: "explicit V1, with Reference + Memo → V1", + opts: &TransactOpts{Version: versionPtr(types.MorphTxVersion1), Reference: refTestPtr(ref), Memo: memoTestPtr(memo)}, + wantVersion: types.MorphTxVersion1, + }, + { + name: "explicit V0, FeeTokenID = 0 → error (V0 requires FeeTokenID > 0)", + opts: &TransactOpts{Version: versionPtr(types.MorphTxVersion0), FeeTokenID: 0}, + wantErr: types.ErrMorphTxV0IllegalExtraParams, + }, + { + name: "explicit V0, with Reference → error", + opts: &TransactOpts{Version: versionPtr(types.MorphTxVersion0), FeeTokenID: 1, Reference: refTestPtr(ref)}, + wantErr: types.ErrMorphTxV0IllegalExtraParams, + }, + { + name: "explicit V0, with Memo → error", + opts: &TransactOpts{Version: versionPtr(types.MorphTxVersion0), FeeTokenID: 1, Memo: memoTestPtr(memo)}, + wantErr: types.ErrMorphTxV0IllegalExtraParams, + }, + { + name: "explicit V1, FeeTokenID=0 + FeeLimit>0 → error", + opts: &TransactOpts{Version: versionPtr(types.MorphTxVersion1), FeeTokenID: 0, FeeLimit: big.NewInt(100)}, + wantErr: types.ErrMorphTxV1IllegalExtraParams, + }, + { + name: "unsupported version 255 → error", + opts: &TransactOpts{Version: versionPtr(255)}, + wantErr: types.ErrMorphTxUnsupportedVersion, + }, + + // === Memo length validation === + { + name: "memo too long → error", + opts: &TransactOpts{FeeTokenID: 1, Memo: memoTestPtr(make([]byte, common.MaxMemoLength+1))}, + wantErr: types.ErrMemoTooLong, + }, + { + name: "memo at max length → ok", + opts: &TransactOpts{FeeTokenID: 1, Memo: memoTestPtr(make([]byte, common.MaxMemoLength))}, + wantVersion: types.MorphTxVersion1, // non-empty memo → V1 + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + version, err := bc.morphTxVersion(tt.opts) + if tt.wantErr != nil { + if err != tt.wantErr { + t.Errorf("error: got %v, want %v", err, tt.wantErr) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if version != tt.wantVersion { + t.Errorf("version: got %d, want %d", version, tt.wantVersion) + } + }) + } +} diff --git a/accounts/external/backend.go b/accounts/external/backend.go index ac620cb5f..fbcc65bbd 100644 --- a/accounts/external/backend.go +++ b/accounts/external/backend.go @@ -221,12 +221,19 @@ func (api *ExternalSigner) SignTx(account accounts.Account, tx *types.Transactio case types.DynamicFeeTxType, types.SetCodeTxType: args.MaxFeePerGas = (*hexutil.Big)(tx.GasFeeCap()) args.MaxPriorityFeePerGas = (*hexutil.Big)(tx.GasTipCap()) - case types.AltFeeTxType: + case types.MorphTxType: args.MaxFeePerGas = (*hexutil.Big)(tx.GasFeeCap()) args.MaxPriorityFeePerGas = (*hexutil.Big)(tx.GasTipCap()) feeTokenID := hexutil.Uint16(tx.FeeTokenID()) args.FeeTokenID = &feeTokenID args.FeeLimit = (*hexutil.Big)(tx.FeeLimit()) + version := hexutil.Uint64(tx.Version()) + args.Version = &version + args.Reference = tx.Reference() + if tx.Memo() != nil { + memo := hexutil.Bytes(*tx.Memo()) + args.Memo = &memo + } default: return nil, fmt.Errorf("unsupported tx type %d", tx.Type()) } diff --git a/cmd/evm/internal/t8ntool/execution.go b/cmd/evm/internal/t8ntool/execution.go index 5e6160287..2a26da15f 100644 --- a/cmd/evm/internal/t8ntool/execution.go +++ b/cmd/evm/internal/t8ntool/execution.go @@ -207,12 +207,24 @@ func (pre *Prestate) Apply(vmConfig vm.Config, chainConfig *params.ChainConfig, receipt.TxHash = tx.Hash() receipt.GasUsed = msgResult.UsedGas receipt.L1Fee = msgResult.L1DataFee - if msg.FeeTokenID() != 0 { - tokenID := msg.FeeTokenID() - receipt.FeeTokenID = &tokenID - receipt.FeeLimit = msg.FeeLimit() - receipt.FeeRate = msgResult.FeeRate - receipt.TokenScale = msgResult.TokenScale + + if msg.FeeTokenID() != 0 || + msg.Version() != 0 || + (msg.Reference() != nil && *msg.Reference() != (common.Reference{})) || + (msg.Memo() != nil && len(*msg.Memo()) > 0) { + if msg.FeeTokenID() != 0 { + tokenID := msg.FeeTokenID() + receipt.FeeTokenID = &tokenID + receipt.FeeLimit = msg.FeeLimit() + receipt.FeeRate = msgResult.FeeRate + receipt.TokenScale = msgResult.TokenScale + } + // Only include V1 fields (version, reference, memo) for V1+ transactions + if msg.Version() >= types.MorphTxVersion1 { + receipt.Version = msg.Version() + receipt.Reference = msg.Reference() + receipt.Memo = msg.Memo() + } } // If the transaction created a contract, store the creation address in the receipt. diff --git a/cmd/geth/chaincmd.go b/cmd/geth/chaincmd.go index 8f3457c0e..6fb52aaa5 100644 --- a/cmd/geth/chaincmd.go +++ b/cmd/geth/chaincmd.go @@ -53,7 +53,7 @@ var ( utils.OverrideMorph203TimeFlag, utils.OverrideViridianTimeFlag, utils.OverrideEmeraldTimeFlag, - utils.OverrideMPTForkTimeFlag, + utils.OverrideJadeForkTimeFlag, }, Category: "BLOCKCHAIN COMMANDS", Description: ` @@ -226,9 +226,9 @@ func initGenesis(ctx *cli.Context) error { v := ctx.Uint64(utils.OverrideEmeraldTimeFlag.Name) overrides.EmeraldTime = &v } - if ctx.IsSet(utils.OverrideMPTForkTimeFlag.Name) { - v := ctx.Uint64(utils.OverrideMPTForkTimeFlag.Name) - overrides.MPTForkTime = &v + if ctx.IsSet(utils.OverrideJadeForkTimeFlag.Name) { + v := ctx.Uint64(utils.OverrideJadeForkTimeFlag.Name) + overrides.JadeForkTime = &v } for _, name := range []string{"chaindata", "lightchaindata"} { diff --git a/cmd/geth/config.go b/cmd/geth/config.go index cfc8961f1..9851e217e 100644 --- a/cmd/geth/config.go +++ b/cmd/geth/config.go @@ -170,9 +170,9 @@ func makeFullNode(ctx *cli.Context) (*node.Node, ethapi.Backend) { v := ctx.Uint64(utils.OverrideEmeraldTimeFlag.Name) cfg.Eth.OverrideEmeraldTime = &v } - if ctx.GlobalIsSet(utils.OverrideMPTForkTimeFlag.Name) { - v := ctx.Uint64(utils.OverrideMPTForkTimeFlag.Name) - cfg.Eth.OverrideMPTForkTime = &v + if ctx.GlobalIsSet(utils.OverrideJadeForkTimeFlag.Name) { + v := ctx.Uint64(utils.OverrideJadeForkTimeFlag.Name) + cfg.Eth.OverrideJadeForkTime = &v } backend, _ := utils.RegisterEthService(stack, &cfg.Eth) diff --git a/cmd/geth/main.go b/cmd/geth/main.go index 22f935c4b..8d4e6228d 100644 --- a/cmd/geth/main.go +++ b/cmd/geth/main.go @@ -74,7 +74,7 @@ var ( utils.OverrideMorph203TimeFlag, utils.OverrideViridianTimeFlag, utils.OverrideEmeraldTimeFlag, - utils.OverrideMPTForkTimeFlag, + utils.OverrideJadeForkTimeFlag, utils.EthashCacheDirFlag, utils.EthashCachesInMemoryFlag, utils.EthashCachesOnDiskFlag, diff --git a/cmd/utils/flags.go b/cmd/utils/flags.go index 164df3602..297e672f9 100644 --- a/cmd/utils/flags.go +++ b/cmd/utils/flags.go @@ -287,9 +287,9 @@ var ( Name: "override.emeraldtime", Usage: "Manually specify the Emerald fork timestamp, overriding the bundled setting", } - OverrideMPTForkTimeFlag = &cli.Uint64Flag{ - Name: "override.mptforktime", - Usage: "Manually specify the MPT fork timestamp, overriding the bundled setting", + OverrideJadeForkTimeFlag = &cli.Uint64Flag{ + Name: "override.jadeforktime", + Usage: "Manually specify the Jade fork timestamp, overriding the bundled setting", } // Light server and client settings LightServeFlag = cli.IntFlag{ diff --git a/common/types.go b/common/types.go index d438a1649..a70f2162b 100644 --- a/common/types.go +++ b/common/types.go @@ -39,13 +39,191 @@ const ( HashLength = 32 // AddressLength is the expected length of the address AddressLength = 20 + // ReferenceLength is the expected length of the reference + ReferenceLength = 32 + // MaxMemoLength is the maximum length of the memo in MorphTx + MaxMemoLength = 64 ) var ( - hashT = reflect.TypeOf(Hash{}) - addressT = reflect.TypeOf(Address{}) + hashT = reflect.TypeOf(Hash{}) + addressT = reflect.TypeOf(Address{}) + referenceT = reflect.TypeOf(Reference{}) ) +// Reference represents the 32 byte reference of a transaction. +type Reference [ReferenceLength]byte + +// BytesToReference sets b to reference. +// If b is larger than len(r), b will be cropped from the left. +func BytesToReference(b []byte) Reference { + var r Reference + r.SetBytes(b) + return r +} + +// BigToReference sets byte representation of b to reference. +// If b is larger than len(r), b will be cropped from the left. +func BigToReference(b *big.Int) Reference { return BytesToReference(b.Bytes()) } + +// HexToReference sets byte representation of s to reference. +// If b is larger than len(r), b will be cropped from the left. +func HexToReference(s string) Reference { return BytesToReference(FromHex(s)) } + +// Bytes gets the byte representation of the underlying reference. +func (r Reference) Bytes() []byte { return r[:] } + +// Big converts a reference to a big integer. +func (r Reference) Big() *big.Int { return new(big.Int).SetBytes(r[:]) } + +// Hex converts a reference to a hex string. +func (r Reference) Hex() string { return hexutil.Encode(r[:]) } + +// TerminalString implements log.TerminalStringer, formatting a string for console +// output during logging. +func (r Reference) TerminalString() string { + return fmt.Sprintf("%x..%x", r[:3], r[29:]) +} + +// String implements the stringer interface and is used also by the logger when +// doing full logging into a file. +func (r Reference) String() string { + return r.Hex() +} + +// Format implements fmt.Formatter. +// Reference supports the %v, %s, %q, %x, %X and %d format verbs. +func (r Reference) Format(s fmt.State, c rune) { + hexb := make([]byte, 2+len(r)*2) + copy(hexb, "0x") + hex.Encode(hexb[2:], r[:]) + + switch c { + case 'x', 'X': + if !s.Flag('#') { + hexb = hexb[2:] + } + if c == 'X' { + hexb = bytes.ToUpper(hexb) + } + fallthrough + case 'v', 's': + s.Write(hexb) + case 'q': + q := []byte{'"'} + s.Write(q) + s.Write(hexb) + s.Write(q) + case 'd': + fmt.Fprint(s, ([len(r)]byte)(r)) + default: + fmt.Fprintf(s, "%%!%c(reference=%x)", c, r) + } +} + +// SetBytes sets the reference to the value of b. +// If b is larger than len(r), b will be cropped from the left. +// The input is right-aligned: leading bytes are zeroed and b is +// copied into the trailing bytes of r. +func (r *Reference) SetBytes(b []byte) { + if len(b) > len(r) { + b = b[len(b)-ReferenceLength:] + } + *r = Reference{} + copy(r[len(r)-len(b):], b) +} + +// UnmarshalText parses a reference in hex syntax. +// Empty strings are treated as zero reference. +func (r *Reference) UnmarshalText(input []byte) error { + // Handle empty string case + if len(input) == 0 || string(input) == "0x" { + *r = Reference{} + return nil + } + return hexutil.UnmarshalFixedText("Reference", input, r[:]) +} + +// UnmarshalJSON parses a reference in hex syntax. +// Empty strings are treated as zero reference. +func (r *Reference) UnmarshalJSON(input []byte) error { + // Handle empty string case: "" or "0x" + if len(input) == 2 && input[0] == '"' && input[1] == '"' { + *r = Reference{} + return nil + } + if len(input) == 4 && string(input) == `"0x"` { + *r = Reference{} + return nil + } + return hexutil.UnmarshalFixedJSON(referenceT, input, r[:]) +} + +// MarshalText returns the hex representation of r. +func (r Reference) MarshalText() ([]byte, error) { + return hexutil.Bytes(r[:]).MarshalText() +} + +// MarshalJSON marshals the original value +func (r Reference) MarshalJSON() ([]byte, error) { + return json.Marshal(r.Hex()) +} + +// Generate implements testing/quick.Generator. +func (r Reference) Generate(rand *rand.Rand, size int) reflect.Value { + m := rand.Intn(len(r)) + for i := len(r) - 1; i > m; i-- { + r[i] = byte(rand.Uint32()) + } + return reflect.ValueOf(r) +} + +// Scan implements Scanner for database/sql. +func (r *Reference) Scan(src interface{}) error { + srcB, ok := src.([]byte) + if !ok { + return fmt.Errorf("can't scan %T into Reference", src) + } + if len(srcB) != ReferenceLength { + return fmt.Errorf("can't scan []byte of len %d into Reference, want %d", len(srcB), ReferenceLength) + } + copy(r[:], srcB) + return nil +} + +// Value implements valuer for database/sql. +func (r Reference) Value() (driver.Value, error) { + return r[:], nil +} + +// ImplementsGraphQLType returns true if Reference implements the specified GraphQL type. +func (Reference) ImplementsGraphQLType(name string) bool { return name == "Bytes32" } + +// UnmarshalGraphQL unmarshals the provided GraphQL query data. +func (r *Reference) UnmarshalGraphQL(input interface{}) error { + var err error + switch input := input.(type) { + case string: + err = r.UnmarshalText([]byte(input)) + default: + err = fmt.Errorf("unexpected type %T for Reference", input) + } + return err +} + +// UnprefixedReference allows marshaling a Reference without 0x prefix. +type UnprefixedReference Reference + +// UnmarshalText decodes the reference from hex. The 0x prefix is optional. +func (r *UnprefixedReference) UnmarshalText(input []byte) error { + return hexutil.UnmarshalFixedUnprefixedText("UnprefixedReference", input, r[:]) +} + +// MarshalText encodes the reference as hex. +func (r UnprefixedReference) MarshalText() ([]byte, error) { + return []byte(hex.EncodeToString(r[:])), nil +} + // Hash represents the 32 byte Keccak256 hash of arbitrary data. type Hash [HashLength]byte diff --git a/core/block_validator.go b/core/block_validator.go index 811ba3106..bbda92745 100644 --- a/core/block_validator.go +++ b/core/block_validator.go @@ -59,6 +59,18 @@ func (v *BlockValidator) ValidateBody(block *types.Block) error { if !v.config.Morph.IsValidBlockSize(block.PayloadSize()) { return ErrInvalidBlockPayloadSize } + // Validate MorphTx for all transactions + isJadeFork := v.config.IsJadeFork(block.Time()) + for _, tx := range block.Transactions() { + // Reject MorphTx V1 before jade fork is active + if !isJadeFork && tx.IsMorphTx() && tx.Version() == types.MorphTxVersion1 { + return types.ErrMorphTxV1NotYetActive + } + // Validate version, memo, and associated field requirements + if err := tx.ValidateMorphTxVersion(); err != nil { + return err + } + } // Header validity is known at this point, check the uncles and transactions header := block.Header() if err := v.engine.VerifyUncles(v.bc, block); err != nil { @@ -102,9 +114,9 @@ func (v *BlockValidator) ValidateState(block *types.Block, statedb *state.StateD // Validate the state root against the received state root and throw // an error if they don't match. // - // XOR condition: Only validate state root when (UseZktrie XOR IsMPTFork) == true + // XOR condition: Only validate state root when (UseZktrie XOR IsJadeFork) == true // This allows cross-format blocks to pass validation without matching local state root. - shouldValidateStateRoot := v.config.Morph.UseZktrie != v.config.IsMPTFork(header.Time) + shouldValidateStateRoot := v.config.Morph.UseZktrie != v.config.IsJadeFork(header.Time) if root := statedb.IntermediateRoot(v.config.IsEIP158(header.Number)); shouldValidateStateRoot && header.Root != root { return fmt.Errorf("invalid merkle root (remote: %x local: %x)", header.Root, root) diff --git a/core/block_validator_test.go b/core/block_validator_test.go index 5969eb9a6..57f5b4d33 100644 --- a/core/block_validator_test.go +++ b/core/block_validator_test.go @@ -232,18 +232,18 @@ func TestCalcGasLimit(t *testing.T) { func TestStateRootValidationXOR(t *testing.T) { tests := []struct { - name string - useZktrie bool - blockTime uint64 - mptForkTime *uint64 - expectValidation bool - description string + name string + useZktrie bool + blockTime uint64 + jadeForkTime *uint64 + expectValidation bool + description string }{ { name: "zkTrie node, before fork", useZktrie: true, blockTime: 500, - mptForkTime: uint64Ptr(1000), + jadeForkTime: uint64Ptr(1000), expectValidation: true, description: "zkTrie node processing zkTrie block (same format) - should validate", }, @@ -251,7 +251,7 @@ func TestStateRootValidationXOR(t *testing.T) { name: "zkTrie node, after fork", useZktrie: true, blockTime: 1500, - mptForkTime: uint64Ptr(1000), + jadeForkTime: uint64Ptr(1000), expectValidation: false, description: "zkTrie node processing MPT block (cross-format) - should skip validation", }, @@ -259,7 +259,7 @@ func TestStateRootValidationXOR(t *testing.T) { name: "MPT node, before fork", useZktrie: false, blockTime: 500, - mptForkTime: uint64Ptr(1000), + jadeForkTime: uint64Ptr(1000), expectValidation: false, description: "MPT node processing zkTrie block (cross-format) - should skip validation", }, @@ -267,7 +267,7 @@ func TestStateRootValidationXOR(t *testing.T) { name: "MPT node, after fork", useZktrie: false, blockTime: 1500, - mptForkTime: uint64Ptr(1000), + jadeForkTime: uint64Ptr(1000), expectValidation: true, description: "MPT node processing MPT block (same format) - should validate", }, @@ -275,7 +275,7 @@ func TestStateRootValidationXOR(t *testing.T) { name: "No fork configured, zkTrie node", useZktrie: true, blockTime: 1500, - mptForkTime: nil, + jadeForkTime: nil, expectValidation: true, description: "No MPT fork, zkTrie node always validates", }, @@ -283,7 +283,7 @@ func TestStateRootValidationXOR(t *testing.T) { name: "No fork configured, MPT node", useZktrie: false, blockTime: 1500, - mptForkTime: nil, + jadeForkTime: nil, expectValidation: false, description: "No MPT fork, MPT node never validates (backwards compat)", }, @@ -295,27 +295,31 @@ func TestStateRootValidationXOR(t *testing.T) { Morph: params.MorphConfig{ UseZktrie: tt.useZktrie, }, - MPTForkTime: tt.mptForkTime, + JadeForkTime: tt.jadeForkTime, } // Calculate XOR condition - isMPTFork := config.IsMPTFork(tt.blockTime) - shouldValidate := tt.useZktrie != isMPTFork + isJadeFork := config.IsJadeFork(tt.blockTime) + shouldValidate := tt.useZktrie != isJadeFork if shouldValidate != tt.expectValidation { - t.Errorf("%s\n Expected validation: %v, got: %v\n UseZktrie=%v, IsMPTFork=%v, XOR=%v", + t.Errorf( + "%s\n Expected validation: %v, got: %v\n UseZktrie=%v, IsJadeFork=%v, XOR=%v", tt.description, tt.expectValidation, shouldValidate, tt.useZktrie, - isMPTFork, - shouldValidate) + isJadeFork, + shouldValidate, + ) } else { - t.Logf("✓ %s: validation=%v (UseZktrie=%v XOR IsMPTFork=%v)", + t.Logf( + "✓ %s: validation=%v (UseZktrie=%v XOR IsJadeFork=%v)", tt.description, shouldValidate, tt.useZktrie, - isMPTFork) + isJadeFork, + ) } }) } diff --git a/core/blockchain.go b/core/blockchain.go index 7a1273e27..47f8c5202 100644 --- a/core/blockchain.go +++ b/core/blockchain.go @@ -403,12 +403,16 @@ func NewBlockChain(db ethdb.Database, cacheConfig *CacheConfig, chainConfig *par bc.wg.Add(1) go bc.futureBlocksLoop() - // Start tx indexer/unindexer. if txLookupLimit != nil { bc.txLookupLimit = *txLookupLimit + // Start tx indexer/unindexer. bc.wg.Add(1) go bc.maintainTxIndex(txIndexBlock) + + // Start reference indexer/unindexer (using same lookup limit as tx index). + bc.wg.Add(1) + go bc.maintainReferenceIndex(txIndexBlock) } // If periodic cache journal is required, spin it up. @@ -619,6 +623,13 @@ func (bc *BlockChain) setHeadBeyondRoot(head uint64, root common.Hash, repair bo } // Rewind the header chain, deleting all block bodies until then delFn := func(db ethdb.KeyValueWriter, hash common.Hash, num uint64) { + // Clean up reference index entries BEFORE deleting the block body, + // because DeleteReferenceIndexEntriesForBlock needs to read transactions + // from the block. Once the body is deleted, these entries become + // permanently stale. + if block := bc.GetBlock(hash, num); block != nil { + rawdb.DeleteReferenceIndexEntriesForBlock(db, block) + } // Ignore the error here since light client won't hit this path frozen, _ := bc.db.Ancients() if num+1 <= frozen { @@ -771,10 +782,27 @@ func (bc *BlockChain) ExportN(w io.Writer, first uint64, last uint64) error { func (bc *BlockChain) writeHeadBlock(block *types.Block) { // Add the block to the canonical chain number scheme and mark as the head batch := bc.db.NewBatch() + + // If the canonical block at this height is being replaced by a different block + // (implicit reorg / L2 sequential overwrite), clean up the old block's reference + // index entries to avoid stale keys. This is necessary because reference index + // keys embed (blockTimestamp, txIndex), so the same tx hash produces different + // keys in different blocks, and simply overwriting won't remove the old key. + oldHash := rawdb.ReadCanonicalHash(bc.db, block.NumberU64()) + if oldHash != (common.Hash{}) && oldHash != block.Hash() { + if oldBlock := bc.GetBlock(oldHash, block.NumberU64()); oldBlock != nil { + rawdb.DeleteReferenceIndexEntriesForBlock(batch, oldBlock) + } else { + log.Warn("Cannot clean reference index entries: old block unavailable", + "number", block.NumberU64(), "oldHash", oldHash) + } + } + rawdb.WriteHeadHeaderHash(batch, block.Hash()) rawdb.WriteHeadFastBlockHash(batch, block.Hash()) rawdb.WriteCanonicalHash(batch, block.Hash(), block.NumberU64()) rawdb.WriteTxLookupEntriesByBlock(batch, block) + rawdb.WriteReferenceIndexEntriesForBlock(batch, block) rawdb.WriteHeadBlockHash(batch, block.Hash()) // Flush the whole batch into the disk, exit the node if failed @@ -1017,13 +1045,15 @@ func (bc *BlockChain) InsertReceiptChain(blockChain types.Blocks, receiptChain [ for _, block := range blockChain { if bc.txLookupLimit == 0 || ancientLimit <= bc.txLookupLimit || block.NumberU64() >= ancientLimit-bc.txLookupLimit { rawdb.WriteTxLookupEntriesByBlock(batch, block) + rawdb.WriteReferenceIndexEntriesForBlock(batch, block) } else if rawdb.ReadTxIndexTail(bc.db) != nil { rawdb.WriteTxLookupEntriesByBlock(batch, block) + rawdb.WriteReferenceIndexEntriesForBlock(batch, block) } stats.processed++ } - // Flush all tx-lookup index data. + // Flush all tx-lookup and reference index data. size += int64(batch.ValueSize()) if err := batch.Write(); err != nil { // The tx index data could not be written. @@ -1102,11 +1132,12 @@ func (bc *BlockChain) InsertReceiptChain(blockChain types.Blocks, receiptChain [ // Write all the data out into the database rawdb.WriteBody(batch, block.Hash(), block.NumberU64(), block.Body()) rawdb.WriteReceipts(batch, block.Hash(), block.NumberU64(), receiptChain[i]) - rawdb.WriteTxLookupEntriesByBlock(batch, block) // Always write tx indices for live blocks, we assume they are needed + rawdb.WriteTxLookupEntriesByBlock(batch, block) // Always write tx indices for live blocks, we assume they are needed + rawdb.WriteReferenceIndexEntriesForBlock(batch, block) // Always write reference indices for live blocks // Write everything belongs to the blocks into the database. So that // we can ensure all components of body is completed(body, receipts, - // tx indexes) + // tx indexes, reference indexes) if batch.ValueSize() >= ethdb.IdealBatchSize { if err := batch.Write(); err != nil { return 0, err @@ -1977,9 +2008,20 @@ func (bc *BlockChain) reorg(oldBlock, newBlock *types.Block) error { // Delete useless indexes right now which includes the non-canonical // transaction indexes, canonical chain indexes which above the head. indexesBatch := bc.db.NewBatch() - for _, tx := range types.TxDifference(deletedTxs, addedTxs) { + trulyDeletedTxs := types.TxDifference(deletedTxs, addedTxs) + for _, tx := range trulyDeletedTxs { rawdb.DeleteTxLookupEntry(indexesBatch, tx.Hash()) } + // Reorg-safe reference index maintenance: + // 1) remove all reference index entries from the old canonical chain segment + // 2) reinsert entries for the newly canonical segment + // This avoids stale keys when the same tx hash moves to a different location. + for _, block := range oldChain { + rawdb.DeleteReferenceIndexEntriesForBlock(indexesBatch, block) + } + for i := len(newChain) - 1; i >= 1; i-- { + rawdb.WriteReferenceIndexEntriesForBlock(indexesBatch, newChain[i]) + } // Delete any canonical number assignments above the new head number := bc.CurrentBlock().NumberU64() for i := number + 1; ; i++ { @@ -2155,6 +2197,94 @@ func (bc *BlockChain) maintainTxIndex(ancients uint64) { } } +// maintainReferenceIndex is responsible for the construction and deletion of the +// reference index for MorphTx transactions. +// +// User can use flag `txlookuplimit` to specify a "recentness" block, below +// which ancient reference indices get deleted. If `txlookuplimit` is 0, it means +// all reference indices will be reserved. +// +// The user can adjust the txlookuplimit value for each launch after fast +// sync, Geth will automatically construct the missing indices and delete +// the extra indices. +func (bc *BlockChain) maintainReferenceIndex(ancients uint64) { + defer bc.wg.Done() + + // Before starting the actual maintenance, we need to handle a special case, + // where user might init Geth with an external ancient database. If so, we + // need to reindex all necessary references before starting to process any + // pruning requests. + if ancients > 0 { + var from = uint64(0) + if bc.txLookupLimit != 0 && ancients > bc.txLookupLimit { + from = ancients - bc.txLookupLimit + } + rawdb.IndexReferences(bc.db, from, ancients, bc.quit) + } + + // indexBlocks reindexes or unindexes references depending on user configuration + indexBlocks := func(tail *uint64, head uint64, done chan struct{}) { + defer func() { done <- struct{}{} }() + + // If the user just upgraded Geth to a new version which supports reference + // index pruning, write the new tail and remove anything older. + if tail == nil { + if bc.txLookupLimit == 0 || head < bc.txLookupLimit { + // Nothing to delete, write the tail and return + rawdb.WriteReferenceIndexTail(bc.db, 0) + } else { + // Prune all stale reference indices and record the reference index tail + rawdb.UnindexReferences(bc.db, 0, head-bc.txLookupLimit+1, bc.quit) + } + return + } + // If a previous indexing existed, make sure that we fill in any missing entries + if bc.txLookupLimit == 0 || head < bc.txLookupLimit { + if *tail > 0 { + rawdb.IndexReferences(bc.db, 0, *tail, bc.quit) + } + return + } + // Update the reference index to the new chain state + if head-bc.txLookupLimit+1 < *tail { + // Reindex a part of missing indices and rewind index tail to HEAD-limit + rawdb.IndexReferences(bc.db, head-bc.txLookupLimit+1, *tail, bc.quit) + } else { + // Unindex a part of stale indices and forward index tail to HEAD-limit + rawdb.UnindexReferences(bc.db, *tail, head-bc.txLookupLimit+1, bc.quit) + } + } + + // Any reindexing done, start listening to chain events and moving the index window + var ( + done chan struct{} // Non-nil if background unindexing or reindexing routine is active. + headCh = make(chan ChainHeadEvent, 1) // Buffered to avoid locking up the event feed + ) + sub := bc.SubscribeChainHeadEvent(headCh) + if sub == nil { + return + } + defer sub.Unsubscribe() + + for { + select { + case head := <-headCh: + if done == nil { + done = make(chan struct{}) + go indexBlocks(rawdb.ReadReferenceIndexTail(bc.db), head.Block.NumberU64(), done) + } + case <-done: + done = nil + case <-bc.quit: + if done != nil { + log.Info("Waiting background reference indexer to exit") + <-done + } + return + } + } +} + // reportBlock logs a bad block error. func (bc *BlockChain) reportBlock(block *types.Block, receipts types.Receipts, err error) { rawdb.WriteBadBlock(bc.db, block) diff --git a/core/blockchain_test.go b/core/blockchain_test.go index 6ac7d57d9..48b4bccfa 100644 --- a/core/blockchain_test.go +++ b/core/blockchain_test.go @@ -3587,3 +3587,593 @@ func TestCurieTransition(t *testing.T) { } } } + +// TestReferenceIndices tests that reference indices are correctly created +// for MorphTx transactions with Reference field when blocks are inserted. +func TestReferenceIndices(t *testing.T) { + // Configure and generate a sample block chain + var ( + gendb = rawdb.NewMemoryDatabase() + key, _ = crypto.HexToECDSA("b71c71a67e1177ad4e901695e1b4b9ee17ae16c6668d313eac2f96dbcda3f291") + address = crypto.PubkeyToAddress(key.PublicKey) + funds = big.NewInt(100000000000000000) + gspec = &Genesis{ + Config: params.TestChainConfig, + Alloc: GenesisAlloc{address: {Balance: funds}}, + BaseFee: big.NewInt(params.InitialBaseFee), + } + genesis = gspec.MustCommit(gendb) + signer = types.LatestSigner(gspec.Config) + ) + + // Create references for testing + ref1 := common.BytesToReference([]byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x01}) + ref2 := common.BytesToReference([]byte{0x02, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x02}) + ref3 := common.BytesToReference([]byte{0x03, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x03}) // shared reference + + var morphTxHashes []common.Hash + var morphTxReferences []common.Reference + + height := uint64(20) + blocks, receipts := GenerateChain(gspec.Config, genesis, ethash.NewFaker(), gendb, int(height), func(i int, block *BlockGen) { + nonce := block.TxNonce(address) + + // Add a regular transaction (no Reference) + regularTx, err := types.SignTx(types.NewTransaction(nonce, common.Address{0x00}, big.NewInt(1000), params.TxGas, block.header.BaseFee, nil), signer, key) + if err != nil { + panic(err) + } + block.AddTx(regularTx) + + // Add MorphTx with different References + var ref common.Reference + if i%3 == 0 { + ref = ref3 // shared reference + } else if i%2 == 0 { + ref = ref2 + } else { + ref = ref1 + } + + morphTx := types.NewTx(&types.MorphTx{ + ChainID: gspec.Config.ChainID, + Nonce: nonce + 1, + GasTipCap: big.NewInt(1), + GasFeeCap: block.header.BaseFee, + Gas: params.TxGas, + To: &common.Address{0x01}, + Value: big.NewInt(100), + Data: nil, + AccessList: nil, + Version: types.MorphTxVersion1, + FeeTokenID: 0, + FeeLimit: nil, + Reference: &ref, + Memo: nil, + }) + signedMorphTx, err := types.SignTx(morphTx, signer, key) + if err != nil { + panic(err) + } + block.AddTx(signedMorphTx) + + morphTxHashes = append(morphTxHashes, signedMorphTx.Hash()) + morphTxReferences = append(morphTxReferences, ref) + }) + + frdir, err := ioutil.TempDir("", "") + if err != nil { + t.Fatalf("failed to create temp freezer dir: %v", err) + } + defer os.Remove(frdir) + ancientDb, err := rawdb.NewDatabaseWithFreezer(rawdb.NewMemoryDatabase(), frdir, "", false) + if err != nil { + t.Fatalf("failed to create temp freezer db: %v", err) + } + gspec.MustCommit(ancientDb) + + // Create blockchain with txLookupLimit = 0 (no limit, index all) + l := uint64(0) + chain, err := NewBlockChain(ancientDb, nil, params.TestChainConfig, ethash.NewFaker(), vm.Config{}, nil, &l) + if err != nil { + t.Fatalf("failed to create tester chain: %v", err) + } + + // Insert headers and receipts + headers := make([]*types.Header, len(blocks)) + for i, block := range blocks { + headers[i] = block.Header() + } + if n, err := chain.InsertHeaderChain(headers, 0); err != nil { + t.Fatalf("failed to insert header %d: %v", n, err) + } + if n, err := chain.InsertReceiptChain(blocks, receipts, 0); err != nil { + t.Fatalf("block %d: failed to insert into chain: %v", n, err) + } + + // Test 1: All MorphTx reference indices should be created + for i, txHash := range morphTxHashes { + ref := morphTxReferences[i] + entries := rawdb.ReadReferenceIndexEntries(chain.db, ref) + found := false + for _, entry := range entries { + if entry.TxHash == txHash { + found = true + break + } + } + if !found { + t.Fatalf("Missing reference index for txHash %s with reference %s", txHash.Hex(), ref.Hex()) + } + } + + // Test 2: Query by reference returns correct results + entries1 := rawdb.ReadReferenceIndexEntries(chain.db, ref1) + entries2 := rawdb.ReadReferenceIndexEntries(chain.db, ref2) + entries3 := rawdb.ReadReferenceIndexEntries(chain.db, ref3) + t.Logf("ref1 entries: %d, ref2 entries: %d, ref3 entries: %d", len(entries1), len(entries2), len(entries3)) + + // Test 3: Entries are sorted by timestamp then txIndex + if len(entries3) >= 2 { + for i := 1; i < len(entries3); i++ { + prev := entries3[i-1] + curr := entries3[i] + if prev.BlockTimestamp > curr.BlockTimestamp { + t.Fatalf("Entries not sorted by timestamp: prev=%d, curr=%d", prev.BlockTimestamp, curr.BlockTimestamp) + } + if prev.BlockTimestamp == curr.BlockTimestamp && prev.TxIndex > curr.TxIndex { + t.Fatalf("Entries not sorted by txIndex: prev=%d, curr=%d", prev.TxIndex, curr.TxIndex) + } + } + } + + chain.Stop() + ancientDb.Close() +} + +// TestReferenceIndexBasicOperations tests the basic CRUD operations for reference indices. +// This test directly tests the index functions without relying on background goroutines. +func TestReferenceIndexBasicOperations(t *testing.T) { + db := rawdb.NewMemoryDatabase() + + ref1 := common.BytesToReference([]byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x01}) + ref2 := common.BytesToReference([]byte{0x02, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x02}) + + txHash1 := common.HexToHash("0x1111111111111111111111111111111111111111111111111111111111111111") + txHash2 := common.HexToHash("0x2222222222222222222222222222222222222222222222222222222222222222") + txHash3 := common.HexToHash("0x3333333333333333333333333333333333333333333333333333333333333333") + + // Test 1: Write and read a single entry + rawdb.WriteReferenceIndexEntry(db, ref1, 100, 0, txHash1) + entries := rawdb.ReadReferenceIndexEntries(db, ref1) + if len(entries) != 1 { + t.Fatalf("Expected 1 entry, got %d", len(entries)) + } + if entries[0].TxHash != txHash1 || entries[0].BlockTimestamp != 100 || entries[0].TxIndex != 0 { + t.Fatalf("Entry mismatch: got %+v", entries[0]) + } + + // Test 2: Multiple entries for the same reference (sorted by timestamp) + rawdb.WriteReferenceIndexEntry(db, ref1, 200, 0, txHash2) + rawdb.WriteReferenceIndexEntry(db, ref1, 150, 1, txHash3) + entries = rawdb.ReadReferenceIndexEntries(db, ref1) + if len(entries) != 3 { + t.Fatalf("Expected 3 entries, got %d", len(entries)) + } + // Verify sorting by timestamp + if entries[0].BlockTimestamp != 100 || entries[1].BlockTimestamp != 150 || entries[2].BlockTimestamp != 200 { + t.Fatalf("Entries not sorted by timestamp: %v", entries) + } + + // Test 3: Different references don't interfere + rawdb.WriteReferenceIndexEntry(db, ref2, 300, 0, txHash1) + entries1 := rawdb.ReadReferenceIndexEntries(db, ref1) + entries2 := rawdb.ReadReferenceIndexEntries(db, ref2) + if len(entries1) != 3 || len(entries2) != 1 { + t.Fatalf("Reference entries interference: ref1=%d, ref2=%d", len(entries1), len(entries2)) + } + + // Test 3.1: Pagination reads only the requested window + page := rawdb.ReadReferenceIndexEntriesPaginated(db, ref1, 1, 1) + if len(page) != 1 { + t.Fatalf("Expected 1 paginated entry, got %d", len(page)) + } + if page[0].BlockTimestamp != 150 || page[0].TxHash != txHash3 { + t.Fatalf("Unexpected paginated entry: %+v", page[0]) + } + page = rawdb.ReadReferenceIndexEntriesPaginated(db, ref1, 10, 10) + if len(page) != 0 { + t.Fatalf("Expected empty page for out-of-range offset, got %d", len(page)) + } + interrupt := make(chan struct{}) + close(interrupt) + pageWithInterrupt, interrupted := rawdb.ReadReferenceIndexEntriesPaginatedWithInterrupt(db, ref1, 0, 10, interrupt) + if !interrupted { + t.Fatal("Expected pagination read to be interrupted") + } + if len(pageWithInterrupt) != 0 { + t.Fatalf("Expected no entries on interrupted pagination read, got %d", len(pageWithInterrupt)) + } + + // Test 4: Delete an entry + rawdb.DeleteReferenceIndexEntry(db, ref1, 100, 0, txHash1) + entries = rawdb.ReadReferenceIndexEntries(db, ref1) + if len(entries) != 2 { + t.Fatalf("Expected 2 entries after delete, got %d", len(entries)) + } + for _, e := range entries { + if e.TxHash == txHash1 { + t.Fatal("Deleted entry still present") + } + } + + // Test 5: Tail operations + if tail := rawdb.ReadReferenceIndexTail(db); tail != nil { + t.Fatalf("Expected nil tail initially, got %d", *tail) + } + rawdb.WriteReferenceIndexTail(db, 100) + if tail := rawdb.ReadReferenceIndexTail(db); tail == nil || *tail != 100 { + t.Fatalf("Expected tail 100, got %v", tail) + } +} + +// TestReferenceIndicesWithMixedTransactions tests that only MorphTx transactions +// with Reference field are indexed, while regular transactions are not. +func TestReferenceIndicesWithMixedTransactions(t *testing.T) { + var ( + gendb = rawdb.NewMemoryDatabase() + key, _ = crypto.HexToECDSA("b71c71a67e1177ad4e901695e1b4b9ee17ae16c6668d313eac2f96dbcda3f291") + address = crypto.PubkeyToAddress(key.PublicKey) + funds = big.NewInt(100000000000000000) + gspec = &Genesis{ + Config: params.TestChainConfig, + Alloc: GenesisAlloc{address: {Balance: funds}}, + BaseFee: big.NewInt(params.InitialBaseFee), + } + genesis = gspec.MustCommit(gendb) + signer = types.LatestSigner(gspec.Config) + ) + + ref1 := common.BytesToReference([]byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x01}) + + var morphTxWithRef *types.Transaction + var morphTxWithoutRef *types.Transaction + var regularTx *types.Transaction + + height := uint64(10) + blocks, receipts := GenerateChain(gspec.Config, genesis, ethash.NewFaker(), gendb, int(height), func(i int, block *BlockGen) { + nonce := block.TxNonce(address) + + // Regular legacy transaction + tx1, err := types.SignTx(types.NewTransaction(nonce, common.Address{0x00}, big.NewInt(1000), params.TxGas, block.header.BaseFee, nil), signer, key) + if err != nil { + panic(err) + } + block.AddTx(tx1) + if i == 5 { + regularTx = tx1 + } + + // MorphTx WITH Reference + tx2 := types.NewTx(&types.MorphTx{ + ChainID: gspec.Config.ChainID, + Nonce: nonce + 1, + GasTipCap: big.NewInt(1), + GasFeeCap: block.header.BaseFee, + Gas: params.TxGas, + To: &common.Address{0x01}, + Value: big.NewInt(100), + Data: nil, + AccessList: nil, + Version: types.MorphTxVersion1, + FeeTokenID: 0, + FeeLimit: nil, + Reference: &ref1, + Memo: nil, + }) + signedTx2, err := types.SignTx(tx2, signer, key) + if err != nil { + panic(err) + } + block.AddTx(signedTx2) + if i == 5 { + morphTxWithRef = signedTx2 + } + + // MorphTx WITHOUT Reference + tx3 := types.NewTx(&types.MorphTx{ + ChainID: gspec.Config.ChainID, + Nonce: nonce + 2, + GasTipCap: big.NewInt(1), + GasFeeCap: block.header.BaseFee, + Gas: params.TxGas, + To: &common.Address{0x02}, + Value: big.NewInt(200), + Data: nil, + AccessList: nil, + Version: types.MorphTxVersion1, + FeeTokenID: 0, + FeeLimit: nil, + Reference: nil, // No reference + Memo: nil, + }) + signedTx3, err := types.SignTx(tx3, signer, key) + if err != nil { + panic(err) + } + block.AddTx(signedTx3) + if i == 5 { + morphTxWithoutRef = signedTx3 + } + }) + + frdir, err := ioutil.TempDir("", "") + if err != nil { + t.Fatalf("failed to create temp freezer dir: %v", err) + } + defer os.Remove(frdir) + + ancientDb, err := rawdb.NewDatabaseWithFreezer(rawdb.NewMemoryDatabase(), frdir, "", false) + if err != nil { + t.Fatalf("failed to create temp freezer db: %v", err) + } + gspec.MustCommit(ancientDb) + + l := uint64(0) + chain, err := NewBlockChain(ancientDb, nil, params.TestChainConfig, ethash.NewFaker(), vm.Config{}, nil, &l) + if err != nil { + t.Fatalf("failed to create tester chain: %v", err) + } + + headers := make([]*types.Header, len(blocks)) + for i, block := range blocks { + headers[i] = block.Header() + } + if n, err := chain.InsertHeaderChain(headers, 0); err != nil { + t.Fatalf("failed to insert header %d: %v", n, err) + } + if n, err := chain.InsertReceiptChain(blocks, receipts, 0); err != nil { + t.Fatalf("block %d: failed to insert into chain: %v", n, err) + } + time.Sleep(50 * time.Millisecond) // Wait for indices initialisation + + // Verify: MorphTx with Reference should be indexed + entries := rawdb.ReadReferenceIndexEntries(chain.db, ref1) + foundMorphTxWithRef := false + for _, entry := range entries { + if entry.TxHash == morphTxWithRef.Hash() { + foundMorphTxWithRef = true + } + // Regular tx and MorphTx without ref should not appear + if entry.TxHash == regularTx.Hash() { + t.Fatal("Regular transaction should not be in reference index") + } + if entry.TxHash == morphTxWithoutRef.Hash() { + t.Fatal("MorphTx without reference should not be in reference index") + } + } + if !foundMorphTxWithRef { + t.Fatal("MorphTx with reference should be in reference index") + } + + // Verify: All MorphTx with ref1 should be indexed (10 blocks * 1 tx per block = 10 entries) + if len(entries) != int(height) { + t.Fatalf("Expected %d reference index entries, got %d", height, len(entries)) + } + + chain.Stop() + ancientDb.Close() +} + +// TestReferenceIndicesMultipleTxsSameReference tests that multiple transactions +// with the same Reference are correctly indexed and queried. +func TestReferenceIndicesMultipleTxsSameReference(t *testing.T) { + var ( + gendb = rawdb.NewMemoryDatabase() + key, _ = crypto.HexToECDSA("b71c71a67e1177ad4e901695e1b4b9ee17ae16c6668d313eac2f96dbcda3f291") + address = crypto.PubkeyToAddress(key.PublicKey) + funds = big.NewInt(100000000000000000) + gspec = &Genesis{ + Config: params.TestChainConfig, + Alloc: GenesisAlloc{address: {Balance: funds}}, + BaseFee: big.NewInt(params.InitialBaseFee), + } + genesis = gspec.MustCommit(gendb) + signer = types.LatestSigner(gspec.Config) + ) + + // All transactions share the same reference + sharedRef := common.BytesToReference([]byte{0xaa, 0xbb, 0xcc, 0xdd, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0xee}) + + var txHashes []common.Hash + + height := uint64(20) + blocks, receipts := GenerateChain(gspec.Config, genesis, ethash.NewFaker(), gendb, int(height), func(i int, block *BlockGen) { + nonce := block.TxNonce(address) + + // Add 2 MorphTx with same reference per block + for j := 0; j < 2; j++ { + morphTx := types.NewTx(&types.MorphTx{ + ChainID: gspec.Config.ChainID, + Nonce: nonce + uint64(j), + GasTipCap: big.NewInt(1), + GasFeeCap: block.header.BaseFee, + Gas: params.TxGas, + To: &common.Address{0x01}, + Value: big.NewInt(100), + Data: nil, + AccessList: nil, + Version: types.MorphTxVersion1, + FeeTokenID: 0, + FeeLimit: nil, + Reference: &sharedRef, + Memo: nil, + }) + signedMorphTx, err := types.SignTx(morphTx, signer, key) + if err != nil { + panic(err) + } + block.AddTx(signedMorphTx) + txHashes = append(txHashes, signedMorphTx.Hash()) + } + }) + + frdir, err := ioutil.TempDir("", "") + if err != nil { + t.Fatalf("failed to create temp freezer dir: %v", err) + } + defer os.Remove(frdir) + + ancientDb, err := rawdb.NewDatabaseWithFreezer(rawdb.NewMemoryDatabase(), frdir, "", false) + if err != nil { + t.Fatalf("failed to create temp freezer db: %v", err) + } + gspec.MustCommit(ancientDb) + + l := uint64(0) + chain, err := NewBlockChain(ancientDb, nil, params.TestChainConfig, ethash.NewFaker(), vm.Config{}, nil, &l) + if err != nil { + t.Fatalf("failed to create tester chain: %v", err) + } + + headers := make([]*types.Header, len(blocks)) + for i, block := range blocks { + headers[i] = block.Header() + } + if n, err := chain.InsertHeaderChain(headers, 0); err != nil { + t.Fatalf("failed to insert header %d: %v", n, err) + } + if n, err := chain.InsertReceiptChain(blocks, receipts, 0); err != nil { + t.Fatalf("block %d: failed to insert into chain: %v", n, err) + } + time.Sleep(50 * time.Millisecond) // Wait for indices initialisation + + // Query all entries for the shared reference + entries := rawdb.ReadReferenceIndexEntries(chain.db, sharedRef) + + // Should have 20 blocks * 2 txs = 40 entries + expectedCount := int(height) * 2 + if len(entries) != expectedCount { + t.Fatalf("Expected %d entries for shared reference, got %d", expectedCount, len(entries)) + } + + // Verify all tx hashes are found + entryHashes := make(map[common.Hash]bool) + for _, entry := range entries { + entryHashes[entry.TxHash] = true + } + for _, txHash := range txHashes { + if !entryHashes[txHash] { + t.Fatalf("Missing tx hash in reference index: %s", txHash.Hex()) + } + } + + // Verify sorting by blockTimestamp and txIndex + for i := 1; i < len(entries); i++ { + prev := entries[i-1] + curr := entries[i] + if prev.BlockTimestamp > curr.BlockTimestamp { + t.Fatalf("Entries not sorted by timestamp: prev=%d, curr=%d", prev.BlockTimestamp, curr.BlockTimestamp) + } + if prev.BlockTimestamp == curr.BlockTimestamp && prev.TxIndex > curr.TxIndex { + t.Fatalf("Entries not sorted by txIndex within same block: prev=%d, curr=%d", prev.TxIndex, curr.TxIndex) + } + } + + chain.Stop() + ancientDb.Close() +} + +// TestReferenceIndicesReorgCleanup ensures reference indices from the old canonical +// branch are removed during reorg, while entries on the new canonical branch remain. +func TestReferenceIndicesReorgCleanup(t *testing.T) { + // Need a config with JadeForkTime enabled so MorphTx V1 is accepted. + jadeCfg := *params.TestChainConfig + jadeCfg.EmeraldTime = new(uint64) // Emerald at time 0 + jadeCfg.JadeForkTime = new(uint64) // Jade at time 0 + + var ( + db = rawdb.NewMemoryDatabase() + key, _ = crypto.HexToECDSA("b71c71a67e1177ad4e901695e1b4b9ee17ae16c6668d313eac2f96dbcda3f291") + address = crypto.PubkeyToAddress(key.PublicKey) + funds = big.NewInt(100000000000000000) + gspec = &Genesis{ + Config: &jadeCfg, + Alloc: GenesisAlloc{address: {Balance: funds}}, + BaseFee: big.NewInt(params.InitialBaseFee), + } + genesis = gspec.MustCommit(db) + signer = types.LatestSigner(gspec.Config) + ) + + ref := common.BytesToReference([]byte{0xaa, 0xbb, 0xcc, 0xdd, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0xee}) + sharedTx := types.NewTx(&types.MorphTx{ + ChainID: gspec.Config.ChainID, + Nonce: 0, + GasTipCap: big.NewInt(1), + GasFeeCap: new(big.Int).Mul(big.NewInt(params.InitialBaseFee), big.NewInt(100)), + Gas: params.TxGas, + To: &common.Address{0x01}, + Value: big.NewInt(1), + Data: nil, + AccessList: nil, + Version: types.MorphTxVersion1, + FeeTokenID: 0, + FeeLimit: nil, + Reference: &ref, + Memo: nil, + }) + signedTx, err := types.SignTx(sharedTx, signer, key) + if err != nil { + t.Fatalf("failed to sign shared tx: %v", err) + } + + chainA, _ := GenerateChain(gspec.Config, genesis, ethash.NewFaker(), db, 3, func(i int, block *BlockGen) { + if i == 0 { + block.AddTx(signedTx) + } + }) + chainB, _ := GenerateChain(gspec.Config, genesis, ethash.NewFaker(), db, 4, func(i int, block *BlockGen) { + if i == 1 { + block.AddTx(signedTx) + } + }) + + l := uint64(0) + chain, err := NewBlockChain(db, nil, &jadeCfg, ethash.NewFaker(), vm.Config{}, nil, &l) + if err != nil { + t.Fatalf("failed to create tester chain: %v", err) + } + defer chain.Stop() + + if _, err := chain.InsertChain(chainA); err != nil { + t.Fatalf("failed to insert chainA: %v", err) + } + entries := rawdb.ReadReferenceIndexEntries(chain.db, ref) + if len(entries) != 1 { + t.Fatalf("expected 1 entry after chainA insert, got %d", len(entries)) + } + oldTimestamp := chainA[0].Time() + if entries[0].BlockTimestamp != oldTimestamp { + t.Fatalf("expected old timestamp %d, got %d", oldTimestamp, entries[0].BlockTimestamp) + } + + if _, err := chain.InsertChain(chainB); err != nil { + t.Fatalf("failed to insert chainB: %v", err) + } + + // After reorg, there should be exactly 1 entry with the new chain's position. + // The original code (trulyDeletedTxs approach) would leave 2 entries (stale + new) + // because the same tx hash exists in both chains but at different positions. + entries = rawdb.ReadReferenceIndexEntries(chain.db, ref) + if len(entries) != 1 { + t.Fatalf("expected exactly 1 entry after reorg cleanup, got %d (stale key leak!)", len(entries)) + } + newTimestamp := chainB[1].Time() + if entries[0].BlockTimestamp != newTimestamp { + t.Fatalf("expected reorged entry timestamp %d, got %d", newTimestamp, entries[0].BlockTimestamp) + } + if entries[0].TxHash != signedTx.Hash() { + t.Fatalf("unexpected tx hash after reorg cleanup: %s", entries[0].TxHash.Hex()) + } +} diff --git a/core/genesis.go b/core/genesis.go index fc5099f25..202480889 100644 --- a/core/genesis.go +++ b/core/genesis.go @@ -149,7 +149,7 @@ type ChainOverrides struct { Morph203Time *uint64 ViridianTime *uint64 EmeraldTime *uint64 - MPTForkTime *uint64 + JadeForkTime *uint64 } // apply applies the chain overrides on the supplied chain config. @@ -166,8 +166,8 @@ func (o *ChainOverrides) apply(cfg *params.ChainConfig) error { if o.EmeraldTime != nil { cfg.EmeraldTime = o.EmeraldTime } - if o.MPTForkTime != nil { - cfg.MPTForkTime = o.MPTForkTime + if o.JadeForkTime != nil { + cfg.JadeForkTime = o.JadeForkTime } return cfg.CheckConfigForkOrder() } diff --git a/core/mpt_fork_test.go b/core/mpt_fork_test.go index 8ddef79e1..5461140ee 100644 --- a/core/mpt_fork_test.go +++ b/core/mpt_fork_test.go @@ -29,58 +29,58 @@ import ( "github.com/morph-l2/go-ethereum/params" ) -// TestMPTForkTransition tests the behavior around MPT fork time -func TestMPTForkTransition(t *testing.T) { +// TestJadeForkTransition tests the behavior around Jade fork time +func TestJadeForkTransition(t *testing.T) { forkTime := uint64(1000) tests := []struct { - name string - blockTime uint64 - mptForkTime *uint64 - expectFork bool - description string + name string + blockTime uint64 + jadeForkTime *uint64 + expectFork bool + description string }{ { - name: "Before fork time", - blockTime: 500, - mptForkTime: &forkTime, - expectFork: false, - description: "Block before MPT fork should not be in fork period", + name: "Before fork time", + blockTime: 500, + jadeForkTime: &forkTime, + expectFork: false, + description: "Block before Jade fork should not be in fork period", }, { - name: "At fork time", - blockTime: 1000, - mptForkTime: &forkTime, - expectFork: true, - description: "Block at exact fork time should be in fork period", + name: "At fork time", + blockTime: 1000, + jadeForkTime: &forkTime, + expectFork: true, + description: "Block at exact fork time should be in fork period", }, { - name: "After fork time", - blockTime: 1500, - mptForkTime: &forkTime, - expectFork: true, - description: "Block after fork time should be in fork period", + name: "After fork time", + blockTime: 1500, + jadeForkTime: &forkTime, + expectFork: true, + description: "Block after fork time should be in fork period", }, { - name: "No fork configured", - blockTime: 1500, - mptForkTime: nil, - expectFork: false, - description: "Without fork config, should never be in fork period", + name: "No fork configured", + blockTime: 1500, + jadeForkTime: nil, + expectFork: false, + description: "Without fork config, should never be in fork period", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { config := ¶ms.ChainConfig{ - MPTForkTime: tt.mptForkTime, + JadeForkTime: tt.jadeForkTime, } - isFork := config.IsMPTFork(tt.blockTime) + isFork := config.IsJadeFork(tt.blockTime) if isFork != tt.expectFork { - t.Errorf("%s: Expected IsMPTFork=%v, got %v", tt.description, tt.expectFork, isFork) + t.Errorf("%s: Expected IsJadeFork=%v, got %v", tt.description, tt.expectFork, isFork) } else { - t.Logf("✓ %s: IsMPTFork=%v (correct)", tt.description, isFork) + t.Logf("✓ %s: IsJadeFork=%v (correct)", tt.description, isFork) } }) } @@ -356,7 +356,7 @@ func TestZkTrieNodeSyncsMPTBlocks(t *testing.T) { forkTime := uint64(10) zkTrieConfig := params.TestChainConfig.Clone() zkTrieConfig.Morph.UseZktrie = true - zkTrieConfig.MPTForkTime = &forkTime // Enable cross-format validation skipping + zkTrieConfig.JadeForkTime = &forkTime // Enable cross-format validation skipping // zkTrie node uses same zkTrie genesis (no GenesisStateRoot override) zkGspec2 := &Genesis{ diff --git a/core/rawdb/accessors_chain.go b/core/rawdb/accessors_chain.go index b402eb25f..f69d0bd80 100644 --- a/core/rawdb/accessors_chain.go +++ b/core/rawdb/accessors_chain.go @@ -639,6 +639,9 @@ type storedReceiptRLP struct { FeeRate *big.Int TokenScale *big.Int FeeLimit *big.Int + Version byte `rlp:"optional"` + Reference []byte `rlp:"optional"` // Use []byte for RLP compatibility (common.Reference is [32]byte, can't decode empty) + Memo []byte `rlp:"optional"` } // ReceiptLogs is a barebone version of ReceiptForStorage which only keeps diff --git a/core/rawdb/accessors_reference_index.go b/core/rawdb/accessors_reference_index.go new file mode 100644 index 000000000..65df2f36b --- /dev/null +++ b/core/rawdb/accessors_reference_index.go @@ -0,0 +1,172 @@ +// Copyright 2024 The go-ethereum Authors +// This file is part of the go-ethereum library. +// +// The go-ethereum library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The go-ethereum library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see . + +package rawdb + +import ( + "encoding/binary" + "math" + "math/big" + + "github.com/morph-l2/go-ethereum/common" + "github.com/morph-l2/go-ethereum/core/types" + "github.com/morph-l2/go-ethereum/ethdb" + "github.com/morph-l2/go-ethereum/log" +) + +// ReferenceIndexEntry stores the transaction location info for reference index lookups. +type ReferenceIndexEntry struct { + BlockTimestamp uint64 + TxIndex uint64 + TxHash common.Hash +} + +// WriteReferenceIndexEntry stores a reference index entry. +// Key format: prefix + reference + blockTimestamp + txIndex + txHash +// Value: nil (empty, we only need the key for lookups) +func WriteReferenceIndexEntry(db ethdb.KeyValueWriter, reference common.Reference, blockTimestamp uint64, txIndex uint64, txHash common.Hash) { + key := referenceIndexKey(reference, blockTimestamp, txIndex, txHash) + if err := db.Put(key, nil); err != nil { + log.Crit("Failed to store reference index entry", "err", err) + } +} + +// WriteReferenceIndexEntriesForBlock writes reference index entries for all MorphTx transactions in a block. +func WriteReferenceIndexEntriesForBlock(db ethdb.KeyValueWriter, block *types.Block) { + for txIndex, tx := range block.Transactions() { + if tx.IsMorphTx() { + if ref := tx.Reference(); ref != nil { + WriteReferenceIndexEntry(db, *ref, block.Time(), uint64(txIndex), tx.Hash()) + } + } + } +} + +// DeleteReferenceIndexEntry removes a reference index entry. +func DeleteReferenceIndexEntry(db ethdb.KeyValueWriter, reference common.Reference, blockTimestamp uint64, txIndex uint64, txHash common.Hash) { + key := referenceIndexKey(reference, blockTimestamp, txIndex, txHash) + if err := db.Delete(key); err != nil { + log.Crit("Failed to delete reference index entry", "err", err) + } +} + +// DeleteReferenceIndexEntriesForBlock removes reference index entries for all MorphTx transactions in a block. +func DeleteReferenceIndexEntriesForBlock(db ethdb.KeyValueWriter, block *types.Block) { + for txIndex, tx := range block.Transactions() { + if tx.IsMorphTx() { + if ref := tx.Reference(); ref != nil { + DeleteReferenceIndexEntry(db, *ref, block.Time(), uint64(txIndex), tx.Hash()) + } + } + } +} + +func parseReferenceIndexEntry(key []byte) (ReferenceIndexEntry, bool) { + // Validate key length: prefix(3) + reference(32) + blockTimestamp(8) + txIndex(8) + txHash(32) = 83 bytes + if len(key) != len(referenceIndexPrefix)+common.ReferenceLength+8+8+common.HashLength { + return ReferenceIndexEntry{}, false + } + offset := len(referenceIndexPrefix) + common.ReferenceLength + blockTimestamp := binary.BigEndian.Uint64(key[offset:]) + offset += 8 + txIndex := binary.BigEndian.Uint64(key[offset:]) + offset += 8 + txHash := common.BytesToHash(key[offset:]) + + return ReferenceIndexEntry{ + BlockTimestamp: blockTimestamp, + TxIndex: txIndex, + TxHash: txHash, + }, true +} + +// ReadReferenceIndexEntriesPaginatedWithInterrupt returns a page of transaction entries for a given reference. +// Results are sorted by blockTimestamp and txIndex (ascending order due to key structure). +// If interrupt is signaled, the call aborts and returns interrupted=true. +func ReadReferenceIndexEntriesPaginatedWithInterrupt(db ethdb.Database, reference common.Reference, offset uint64, limit uint64, interrupt <-chan struct{}) ([]ReferenceIndexEntry, bool) { + if limit == 0 { + return nil, false + } + prefix := referenceIndexKeyPrefix(reference) + it := db.NewIterator(prefix, nil) + defer it.Release() + + end := uint64(math.MaxUint64) + if offset <= math.MaxUint64-limit { + end = offset + limit + } + // Cap initial allocation to avoid huge pre-allocation when limit is very large + initCap := limit + if initCap > 1024 { + initCap = 1024 + } + entries := make([]ReferenceIndexEntry, 0, initCap) + index := uint64(0) + for it.Next() { + select { + case <-interrupt: + return nil, true + default: + } + entry, ok := parseReferenceIndexEntry(it.Key()) + if !ok { + continue + } + if index < offset { + index++ + continue + } + if index >= end { + break + } + entries = append(entries, entry) + index++ + } + if it.Error() != nil { + log.Error("Failed to iterate paginated reference index entries", "reference", reference.Hex(), "err", it.Error()) + } + return entries, false +} + +// ReadReferenceIndexEntriesPaginated returns a page of transaction entries for a given reference. +// Results are sorted by blockTimestamp and txIndex (ascending order due to key structure). +func ReadReferenceIndexEntriesPaginated(db ethdb.Database, reference common.Reference, offset uint64, limit uint64) []ReferenceIndexEntry { + entries, _ := ReadReferenceIndexEntriesPaginatedWithInterrupt(db, reference, offset, limit, nil) + return entries +} + +// ReadReferenceIndexEntries returns all transaction entries for a given reference. +// Results are sorted by blockTimestamp and txIndex (ascending order due to key structure). +func ReadReferenceIndexEntries(db ethdb.Database, reference common.Reference) []ReferenceIndexEntry { + return ReadReferenceIndexEntriesPaginated(db, reference, 0, math.MaxUint64) +} + +// ReadReferenceIndexTail retrieves the block number whose reference index is the oldest stored. +func ReadReferenceIndexTail(db ethdb.KeyValueReader) *uint64 { + data, err := db.Get(referenceIndexTailKey) + if err != nil || len(data) == 0 { + return nil + } + number := new(big.Int).SetBytes(data).Uint64() + return &number +} + +// WriteReferenceIndexTail stores the block number whose reference index is the oldest stored. +func WriteReferenceIndexTail(db ethdb.KeyValueWriter, blockNumber uint64) { + if err := db.Put(referenceIndexTailKey, new(big.Int).SetUint64(blockNumber).Bytes()); err != nil { + log.Crit("Failed to store reference index tail", "err", err) + } +} diff --git a/core/rawdb/accessors_reference_index_test.go b/core/rawdb/accessors_reference_index_test.go new file mode 100644 index 000000000..85ffd6a1c --- /dev/null +++ b/core/rawdb/accessors_reference_index_test.go @@ -0,0 +1,261 @@ +// Copyright 2024 The go-ethereum Authors +// This file is part of the go-ethereum library. +// +// The go-ethereum library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The go-ethereum library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see . + +package rawdb + +import ( + "testing" + + "github.com/morph-l2/go-ethereum/common" + "github.com/morph-l2/go-ethereum/ethdb" +) + +// ---------- helpers ---------- + +func makeRef(b byte) common.Reference { + var ref common.Reference + ref[0] = b + return ref +} + +func makeTxHash(b byte) common.Hash { + var h common.Hash + h[0] = b + return h +} + +// writeN writes n reference index entries for the given reference, with +// blockTimestamp = base+i, txIndex = i, txHash = makeTxHash(byte(i)). +func writeN(db ethdb.Database, ref common.Reference, n int, base uint64) { + for i := 0; i < n; i++ { + WriteReferenceIndexEntry(db, ref, base+uint64(i), uint64(i), makeTxHash(byte(i))) + } +} + +// ---------- Issue 1: Full-scan DoS verification ---------- + +// TestPagination_FullReadMatchesOld verifies that ReadReferenceIndexEntries +// (backward-compat wrapper) still returns every entry — confirming it was a +// full scan prior to the pagination fix. +func TestPagination_FullReadMatchesOld(t *testing.T) { + db := NewMemoryDatabase() + ref := makeRef(0x01) + writeN(db, ref, 50, 1000) + + all := ReadReferenceIndexEntries(db, ref) + if len(all) != 50 { + t.Fatalf("expected 50 entries from full read, got %d", len(all)) + } +} + +// TestPagination_LimitStopsEarly proves that the paginated reader returns +// exactly `limit` entries and does NOT load everything. +func TestPagination_LimitStopsEarly(t *testing.T) { + db := NewMemoryDatabase() + ref := makeRef(0x02) + writeN(db, ref, 200, 0) // 200 entries stored + + // Request only the first 10 + page := ReadReferenceIndexEntriesPaginated(db, ref, 0, 10) + if len(page) != 10 { + t.Fatalf("expected 10 entries, got %d", len(page)) + } + + // Verify the entries are the first 10 (sorted by timestamp) + for i, e := range page { + if e.BlockTimestamp != uint64(i) { + t.Fatalf("entry %d: expected timestamp %d, got %d", i, i, e.BlockTimestamp) + } + } +} + +// TestPagination_OffsetSkips verifies that offset correctly skips entries +// and returns the right slice window. +func TestPagination_OffsetSkips(t *testing.T) { + db := NewMemoryDatabase() + ref := makeRef(0x03) + writeN(db, ref, 50, 100) // timestamps 100..149 + + // Read entries 10..14 (offset=10, limit=5) + page := ReadReferenceIndexEntriesPaginated(db, ref, 10, 5) + if len(page) != 5 { + t.Fatalf("expected 5 entries, got %d", len(page)) + } + if page[0].BlockTimestamp != 110 { + t.Fatalf("expected first entry timestamp 110, got %d", page[0].BlockTimestamp) + } + if page[4].BlockTimestamp != 114 { + t.Fatalf("expected last entry timestamp 114, got %d", page[4].BlockTimestamp) + } +} + +// TestPagination_OffsetBeyondEnd returns empty result instead of error. +func TestPagination_OffsetBeyondEnd(t *testing.T) { + db := NewMemoryDatabase() + ref := makeRef(0x04) + writeN(db, ref, 10, 0) + + page := ReadReferenceIndexEntriesPaginated(db, ref, 999, 10) + if len(page) != 0 { + t.Fatalf("expected 0 entries for out-of-range offset, got %d", len(page)) + } +} + +// TestPagination_LimitZero returns empty. +func TestPagination_LimitZero(t *testing.T) { + db := NewMemoryDatabase() + ref := makeRef(0x05) + writeN(db, ref, 10, 0) + + page := ReadReferenceIndexEntriesPaginated(db, ref, 0, 0) + if len(page) != 0 { + t.Fatalf("expected 0 entries for limit=0, got %d", len(page)) + } +} + +// TestPagination_LimitExceedsTotal returns only what exists. +func TestPagination_LimitExceedsTotal(t *testing.T) { + db := NewMemoryDatabase() + ref := makeRef(0x06) + writeN(db, ref, 5, 0) + + page := ReadReferenceIndexEntriesPaginated(db, ref, 0, 100) + if len(page) != 5 { + t.Fatalf("expected 5 entries, got %d", len(page)) + } +} + +// ---------- Issue 3: Interrupt verification ---------- + +// TestInterrupt_ImmediateStop verifies that a pre-closed interrupt channel +// causes the reader to return immediately with interrupted=true and no results. +func TestInterrupt_ImmediateStop(t *testing.T) { + db := NewMemoryDatabase() + ref := makeRef(0x07) + writeN(db, ref, 100, 0) + + ch := make(chan struct{}) + close(ch) // pre-closed + + entries, interrupted := ReadReferenceIndexEntriesPaginatedWithInterrupt(db, ref, 0, 100, ch) + if !interrupted { + t.Fatal("expected interrupted=true for pre-closed channel") + } + if len(entries) != 0 { + t.Fatalf("expected 0 entries on interrupt, got %d", len(entries)) + } +} + +// TestInterrupt_NilChannel means no interrupt — should read normally. +func TestInterrupt_NilChannel(t *testing.T) { + db := NewMemoryDatabase() + ref := makeRef(0x08) + writeN(db, ref, 20, 0) + + entries, interrupted := ReadReferenceIndexEntriesPaginatedWithInterrupt(db, ref, 0, 20, nil) + if interrupted { + t.Fatal("expected interrupted=false for nil channel") + } + if len(entries) != 20 { + t.Fatalf("expected 20 entries, got %d", len(entries)) + } +} + +// ---------- Delete / Write consistency ---------- + +// TestDeleteReferenceIndexEntry verifies single-entry deletion. +func TestDeleteReferenceIndexEntry(t *testing.T) { + db := NewMemoryDatabase() + ref := makeRef(0x09) + txHash := makeTxHash(0xAA) + + WriteReferenceIndexEntry(db, ref, 500, 3, txHash) + entries := ReadReferenceIndexEntries(db, ref) + if len(entries) != 1 { + t.Fatalf("expected 1 entry, got %d", len(entries)) + } + + DeleteReferenceIndexEntry(db, ref, 500, 3, txHash) + entries = ReadReferenceIndexEntries(db, ref) + if len(entries) != 0 { + t.Fatalf("expected 0 entries after delete, got %d", len(entries)) + } +} + +// ---------- Issue 2: Reorg stale entry scenario (rawdb level) ---------- + +// TestReorg_StaleKeyScenario simulates the exact bug where the same tx hash +// exists in both old and new chains at DIFFERENT positions. In the old code, +// the "trulyDeletedTxs" approach would miss the old entry because the tx is +// not "truly deleted" (it's in both chains). Our fix deletes ALL old chain +// entries unconditionally. +func TestReorg_StaleKeyScenario(t *testing.T) { + db := NewMemoryDatabase() + ref := makeRef(0x0A) + txHash := makeTxHash(0xBB) + + // Simulate old chain: tx at block timestamp 1000, txIndex 0 + WriteReferenceIndexEntry(db, ref, 1000, 0, txHash) + + // Verify one entry exists + entries := ReadReferenceIndexEntries(db, ref) + if len(entries) != 1 || entries[0].BlockTimestamp != 1000 { + t.Fatalf("setup: expected 1 entry at ts=1000, got %d entries", len(entries)) + } + + // === Simulate the BUG (old trulyDeletedTxs approach) === + // If tx is in both old and new chains, old code wouldn't delete the old entry. + // It would just write a new one, resulting in 2 entries for the same tx. + WriteReferenceIndexEntry(db, ref, 2000, 5, txHash) // new chain position + + entries = ReadReferenceIndexEntries(db, ref) + if len(entries) != 2 { + t.Fatalf("bug scenario: expected 2 entries (stale + new), got %d", len(entries)) + } + t.Logf("BUG CONFIRMED: %d stale+new entries for same tx hash (old-code behavior)", len(entries)) + + // === Simulate the FIX (delete old chain entries, then write new) === + // Delete the old chain entry + DeleteReferenceIndexEntry(db, ref, 1000, 0, txHash) + + entries = ReadReferenceIndexEntries(db, ref) + if len(entries) != 1 { + t.Fatalf("fix: expected 1 entry after cleanup, got %d", len(entries)) + } + if entries[0].BlockTimestamp != 2000 { + t.Fatalf("fix: expected surviving entry at ts=2000, got ts=%d", entries[0].BlockTimestamp) + } + if entries[0].TxIndex != 5 { + t.Fatalf("fix: expected surviving entry at txIdx=5, got txIdx=%d", entries[0].TxIndex) + } + t.Logf("FIX VERIFIED: only 1 correct entry remains after cleanup") +} + +// TestParseReferenceIndexEntry_InvalidKey ensures malformed keys are rejected. +func TestParseReferenceIndexEntry_InvalidKey(t *testing.T) { + // Too short key + _, ok := parseReferenceIndexEntry([]byte{0x01, 0x02, 0x03}) + if ok { + t.Fatal("expected invalid parse for short key") + } + // Correct length but arbitrary bytes (should still parse) + key := make([]byte, len(referenceIndexPrefix)+common.ReferenceLength+8+8+common.HashLength) + copy(key, referenceIndexPrefix) + _, ok = parseReferenceIndexEntry(key) + if !ok { + t.Fatal("expected valid parse for correct-length key") + } +} diff --git a/core/rawdb/reference_index_iterator.go b/core/rawdb/reference_index_iterator.go new file mode 100644 index 000000000..fc69a86ee --- /dev/null +++ b/core/rawdb/reference_index_iterator.go @@ -0,0 +1,337 @@ +// Copyright 2024 The go-ethereum Authors +// This file is part of the go-ethereum library. +// +// The go-ethereum library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The go-ethereum library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see . + +package rawdb + +import ( + "runtime" + "sync/atomic" + "time" + + "github.com/morph-l2/go-ethereum/common" + "github.com/morph-l2/go-ethereum/common/prque" + "github.com/morph-l2/go-ethereum/core/types" + "github.com/morph-l2/go-ethereum/ethdb" + "github.com/morph-l2/go-ethereum/log" + "github.com/morph-l2/go-ethereum/rlp" +) + +// blockReferenceInfo contains reference information for a block +type blockReferenceInfo struct { + number uint64 + blockTimestamp uint64 + references []referenceEntry +} + +// referenceEntry contains a single reference entry +type referenceEntry struct { + reference common.Reference + txIndex uint64 + txHash common.Hash +} + +// iterateReferences iterates over all MorphTx transactions with references in the (canon) block +// number(s) given, and yields the reference entries on a channel. If there is a signal +// received from interrupt channel, the iteration will be aborted and result +// channel will be closed. +func iterateReferences(db ethdb.Database, from uint64, to uint64, reverse bool, interrupt chan struct{}) chan *blockReferenceInfo { + if to == from { + return nil + } + threads := to - from + if cpus := runtime.NumCPU(); threads > uint64(cpus) { + threads = uint64(cpus) + } + + type numberBodyRlp struct { + number uint64 + rlp rlp.RawValue + header *types.Header + } + + var ( + rlpCh = make(chan *numberBodyRlp, threads*2) + resultCh = make(chan *blockReferenceInfo, threads*2) + ) + + // lookup runs in one instance + lookup := func() { + n, end := from, to + if reverse { + n, end = to-1, from-1 + } + defer close(rlpCh) + for n != end { + data := ReadCanonicalBodyRLP(db, n) + hash := ReadCanonicalHash(db, n) + header := ReadHeader(db, hash, n) + // Feed the block to the aggregator, or abort on interrupt + select { + case rlpCh <- &numberBodyRlp{n, data, header}: + case <-interrupt: + return + } + if reverse { + n-- + } else { + n++ + } + } + } + + // process runs in parallel + nThreadsAlive := int32(threads) + process := func() { + defer func() { + // Last processor closes the result channel + if atomic.AddInt32(&nThreadsAlive, -1) == 0 { + close(resultCh) + } + }() + for data := range rlpCh { + if data.header == nil { + log.Warn("Failed to read header for reference indexing", "block", data.number) + // Emit placeholder result to maintain contiguous block numbers + select { + case resultCh <- &blockReferenceInfo{number: data.number}: + case <-interrupt: + return + } + continue + } + var body types.Body + if err := rlp.DecodeBytes(data.rlp, &body); err != nil { + log.Warn("Failed to decode block body", "block", data.number, "error", err) + // Emit placeholder result to maintain contiguous block numbers + select { + case resultCh <- &blockReferenceInfo{number: data.number, blockTimestamp: data.header.Time}: + case <-interrupt: + return + } + continue + } + + var refs []referenceEntry + for txIndex, tx := range body.Transactions { + if tx.IsMorphTx() { + if ref := tx.Reference(); ref != nil { + refs = append(refs, referenceEntry{ + reference: *ref, + txIndex: uint64(txIndex), + txHash: tx.Hash(), + }) + } + } + } + + // Always send result for every block (even if no references) to maintain + // contiguous block numbers for gap-filling logic in indexReferences + result := &blockReferenceInfo{ + number: data.number, + blockTimestamp: data.header.Time, + references: refs, + } + // Feed the block to the aggregator, or abort on interrupt + select { + case resultCh <- result: + case <-interrupt: + return + } + } + } + + go lookup() // start the sequential db accessor + for i := 0; i < int(threads); i++ { + go process() + } + return resultCh +} + +// indexReferences creates reference indices of the specified block range. +// +// This function iterates canonical chain in reverse order, it has one main advantage: +// We can write reference index tail flag periodically even without the whole indexing +// procedure is finished. So that we can resume indexing procedure next time quickly. +// +// There is a passed channel, the whole procedure will be interrupted if any +// signal received. +func indexReferences(db ethdb.Database, from uint64, to uint64, interrupt chan struct{}, hook func(uint64) bool) { + // short circuit for invalid range + if from >= to { + return + } + var ( + resultCh = iterateReferences(db, from, to, true, interrupt) + batch = db.NewBatch() + start = time.Now() + logged = start.Add(-7 * time.Second) + // Since we iterate in reverse, we expect the first number to come + // in to be [to-1]. Therefore, setting lastNum to means that the + // prqueue gap-evaluation will work correctly + lastNum = to + queue = prque.New(nil) + // for stats reporting + blocks, refs = 0, 0 + ) + for chanDelivery := range resultCh { + // Push the delivery into the queue and process contiguous ranges. + // Since we iterate in reverse, so lower numbers have lower prio, and + // we can use the number directly as prio marker + queue.Push(chanDelivery, int64(chanDelivery.number)) + for !queue.Empty() { + // If the next available item is gapped, return + if _, priority := queue.Peek(); priority != int64(lastNum-1) { + break + } + // For testing + if hook != nil && !hook(lastNum-1) { + break + } + // Next block available, pop it off and index it + delivery := queue.PopItem().(*blockReferenceInfo) + lastNum = delivery.number + for _, ref := range delivery.references { + WriteReferenceIndexEntry(batch, ref.reference, delivery.blockTimestamp, ref.txIndex, ref.txHash) + } + blocks++ + refs += len(delivery.references) + // If enough data was accumulated in memory or we're at the last block, dump to disk + if batch.ValueSize() > ethdb.IdealBatchSize { + WriteReferenceIndexTail(batch, lastNum) // Also write the tail here + if err := batch.Write(); err != nil { + log.Crit("Failed writing batch to db", "error", err) + return + } + batch.Reset() + } + // If we've spent too much time already, notify the user of what we're doing + if time.Since(logged) > 8*time.Second { + log.Info("Indexing references", "blocks", blocks, "refs", refs, "tail", lastNum, "total", to-from, "elapsed", common.PrettyDuration(time.Since(start))) + logged = time.Now() + } + } + } + // Flush the new indexing tail and the last committed data. It can also happen + // that the last batch is empty because nothing to index, but the tail has to + // be flushed anyway. + WriteReferenceIndexTail(batch, lastNum) + if err := batch.Write(); err != nil { + log.Crit("Failed writing batch to db", "error", err) + return + } + select { + case <-interrupt: + log.Debug("Reference indexing interrupted", "blocks", blocks, "refs", refs, "tail", lastNum, "elapsed", common.PrettyDuration(time.Since(start))) + default: + log.Info("Indexed references", "blocks", blocks, "refs", refs, "tail", lastNum, "elapsed", common.PrettyDuration(time.Since(start))) + } +} + +// IndexReferences creates reference indices of the specified block range. +// +// This function iterates canonical chain in reverse order, it has one main advantage: +// We can write reference index tail flag periodically even without the whole indexing +// procedure is finished. So that we can resume indexing procedure next time quickly. +// +// There is a passed channel, the whole procedure will be interrupted if any +// signal received. +func IndexReferences(db ethdb.Database, from uint64, to uint64, interrupt chan struct{}) { + indexReferences(db, from, to, interrupt, nil) +} + +// unindexReferences removes reference indices of the specified block range. +// +// There is a passed channel, the whole procedure will be interrupted if any +// signal received. +func unindexReferences(db ethdb.Database, from uint64, to uint64, interrupt chan struct{}, hook func(uint64) bool) { + // short circuit for invalid range + if from >= to { + return + } + var ( + resultCh = iterateReferences(db, from, to, false, interrupt) + batch = db.NewBatch() + start = time.Now() + logged = start.Add(-7 * time.Second) + // we expect the first number to come in to be [from]. Therefore, setting + // nextNum to from means that the prqueue gap-evaluation will work correctly + nextNum = from + queue = prque.New(nil) + // for stats reporting + blocks, refs = 0, 0 + ) + // Otherwise spin up the concurrent iterator and unindexer + for delivery := range resultCh { + // Push the delivery into the queue and process contiguous ranges. + queue.Push(delivery, -int64(delivery.number)) + for !queue.Empty() { + // If the next available item is gapped, return + if _, priority := queue.Peek(); -priority != int64(nextNum) { + break + } + // For testing + if hook != nil && !hook(nextNum) { + break + } + delivery := queue.PopItem().(*blockReferenceInfo) + nextNum = delivery.number + 1 + for _, ref := range delivery.references { + DeleteReferenceIndexEntry(batch, ref.reference, delivery.blockTimestamp, ref.txIndex, ref.txHash) + } + refs += len(delivery.references) + blocks++ + + // If enough data was accumulated in memory or we're at the last block, dump to disk + // A batch counts the size of deletion as '1', so we need to flush more + // often than that. + if blocks%1000 == 0 { + WriteReferenceIndexTail(batch, nextNum) + if err := batch.Write(); err != nil { + log.Crit("Failed writing batch to db", "error", err) + return + } + batch.Reset() + } + // If we've spent too much time already, notify the user of what we're doing + if time.Since(logged) > 8*time.Second { + log.Info("Unindexing references", "blocks", blocks, "refs", refs, "total", to-from, "elapsed", common.PrettyDuration(time.Since(start))) + logged = time.Now() + } + } + } + // Flush the new indexing tail and the last committed data. It can also happen + // that the last batch is empty because nothing to unindex, but the tail has to + // be flushed anyway. + WriteReferenceIndexTail(batch, nextNum) + if err := batch.Write(); err != nil { + log.Crit("Failed writing batch to db", "error", err) + return + } + select { + case <-interrupt: + log.Debug("Reference unindexing interrupted", "blocks", blocks, "refs", refs, "tail", to, "elapsed", common.PrettyDuration(time.Since(start))) + default: + log.Info("Unindexed references", "blocks", blocks, "refs", refs, "tail", to, "elapsed", common.PrettyDuration(time.Since(start))) + } +} + +// UnindexReferences removes reference indices of the specified block range. +// +// There is a passed channel, the whole procedure will be interrupted if any +// signal received. +func UnindexReferences(db ethdb.Database, from uint64, to uint64, interrupt chan struct{}) { + unindexReferences(db, from, to, interrupt, nil) +} diff --git a/core/rawdb/schema.go b/core/rawdb/schema.go index c8d0de0d9..ea77b71ee 100644 --- a/core/rawdb/schema.go +++ b/core/rawdb/schema.go @@ -126,6 +126,11 @@ var ( rollupBatchL1DataFeePrefix = []byte("R-df") rollupBatchHeadBatchHasFeeKey = []byte("R-hbf") + // Reference index: Key(prefix + reference + blockTimestamp + txIndex + txHash) : Value (nil) + referenceIndexPrefix = []byte("ref") + // Track the oldest block whose reference indices have been indexed + referenceIndexTailKey = []byte("ReferenceIndexTail") + // diskStateRoot mapping diskStateRootPrefix = []byte("dsr") // diskStateRootPrefix + headerRoot -> diskRoot ) @@ -317,6 +322,28 @@ func RollupBatchL1DataFeeKey(batchIndex uint64) []byte { return append(rollupBatchL1DataFeePrefix, encodeBigEndian(batchIndex)...) } +// referenceIndexKey = referenceIndexPrefix + reference + blockTimestamp + txIndex + txHash +// Key format: prefix(3) + reference(32) + blockTimestamp(8) + txIndex(8) + txHash(32) = 83 bytes +func referenceIndexKey(reference common.Reference, blockTimestamp uint64, txIndex uint64, txHash common.Hash) []byte { + key := make([]byte, len(referenceIndexPrefix)+common.ReferenceLength+8+8+common.HashLength) + copy(key, referenceIndexPrefix) + offset := len(referenceIndexPrefix) + copy(key[offset:], reference[:]) + offset += common.ReferenceLength + binary.BigEndian.PutUint64(key[offset:], blockTimestamp) + offset += 8 + binary.BigEndian.PutUint64(key[offset:], txIndex) + offset += 8 + copy(key[offset:], txHash[:]) + return key +} + +// referenceIndexKeyPrefix = referenceIndexPrefix + reference +// Used for range queries by reference +func referenceIndexKeyPrefix(reference common.Reference) []byte { + return append(referenceIndexPrefix, reference[:]...) +} + // diskStateRootKey = diskStateRootPrefix + headerRoot func diskStateRootKey(headerRoot common.Hash) []byte { return append(diskStateRootPrefix, headerRoot.Bytes()...) diff --git a/core/state_processor.go b/core/state_processor.go index 8daab4d6f..06f98dd01 100644 --- a/core/state_processor.go +++ b/core/state_processor.go @@ -192,12 +192,18 @@ func ApplyTransactionWithEVM(msg Message, config *params.ChainConfig, gp *GasPoo receipt.BlockNumber = blockNumber receipt.TransactionIndex = uint(statedb.TxIndex()) receipt.L1Fee = result.L1DataFee - if tx.IsAltFeeTx() { + if tx.IsMorphTx() { tokenID := tx.FeeTokenID() receipt.FeeTokenID = &tokenID receipt.FeeLimit = tx.FeeLimit() receipt.FeeRate = result.FeeRate receipt.TokenScale = result.TokenScale + // Only include V1 fields (version, reference, memo) for V1+ transactions + if tx.Version() >= types.MorphTxVersion1 { + receipt.Version = tx.Version() + receipt.Reference = tx.Reference() + receipt.Memo = tx.Memo() + } } return receipt, err diff --git a/core/state_transition.go b/core/state_transition.go index a062a18df..72c0efca8 100644 --- a/core/state_transition.go +++ b/core/state_transition.go @@ -100,6 +100,9 @@ type Message interface { SetCodeAuthorizations() []types.SetCodeAuthorization FeeTokenID() uint16 FeeLimit() *big.Int + Version() uint8 + Reference() *common.Reference + Memo() *[]byte } // ExecutionResult includes all output after executing given evm diff --git a/core/tx_list.go b/core/tx_list.go index e7edda2e8..d36ccc594 100644 --- a/core/tx_list.go +++ b/core/tx_list.go @@ -343,7 +343,7 @@ func (l *txList) Add(tx *types.Transaction, state *state.StateDB, priceBump uint } l.txs.Put(tx) ethCost := big.NewInt(0) - if tx.IsAltFeeTx() { + if tx.IsMorphTxWithAltFee() { ethCost = new(big.Int).Set(tx.Value()) altCost, err := fees.EthToAlt(state, tx.FeeTokenID(), new(big.Int).Add(tx.GasFee(), l1DataFee)) if err != nil { @@ -394,7 +394,7 @@ func (l *txList) Filter(costLimit *big.Int, gasLimit uint64, altCostLimit map[ui // Filter out all the transactions above the account's funds removed := l.txs.Filter(func(tx *types.Transaction) bool { allLower := true - if tx.IsAltFeeTx() { + if tx.IsMorphTxWithAltFee() { for id, limit := range altCostLimit { lower := l.costcap.Alt(id).Cmp(limit) <= 0 if !lower { diff --git a/core/tx_pool.go b/core/tx_pool.go index a07170239..76f96024e 100644 --- a/core/tx_pool.go +++ b/core/tx_pool.go @@ -257,6 +257,7 @@ type TxPool struct { eip1559 bool // Fork indicator whether we are using EIP-1559 type transactions. shanghai bool // Fork indicator whether we are in the Shanghai stage. eip7702 bool // Fork indicator whether we are in the Morph 3.0.0 stage. + jade bool // Fork indicator whether we are in the Jade stage. currentState *state.StateDB // Current state in the blockchain head currentHead *big.Int // Current blockchain head @@ -668,15 +669,24 @@ func (pool *TxPool) validateTx(tx *types.Transaction, local bool) error { if !pool.eip1559 && tx.Type() == types.DynamicFeeTxType { return ErrTxTypeNotSupported } - // Reject erc20 fee transactions until EIP-1559 activates. - if !pool.eip1559 && tx.Type() == types.AltFeeTxType { + if !pool.eip1559 && tx.Type() == types.MorphTxType { return ErrTxTypeNotSupported } if !pool.eip7702 && tx.Type() == types.SetCodeTxType { return ErrTxTypeNotSupported } + // Reject MorphTx V1 before jade fork is active + if !pool.jade && tx.IsMorphTx() && tx.Version() == types.MorphTxVersion1 { + return types.ErrMorphTxV1NotYetActive + } + + // Validate MorphTx version, memo, and associated field requirements + if err := tx.ValidateMorphTxVersion(); err != nil { + return err + } + // Reject transactions over defined size to prevent DOS attacks if uint64(tx.Size()) > uint64(pool.txMaxSize) { return ErrOversizedData @@ -724,7 +734,7 @@ func (pool *TxPool) validateTx(tx *types.Transaction, local bool) error { // 1. Check balance >= transaction cost (V + GP * GL) to maintain compatibility with the logic without considering L1 data fee. // Transactor should have enough funds to cover the costs // cost == V + GP * GL - if tx.IsAltFeeTx() { + if tx.IsMorphTxWithAltFee() { active, err := fees.IsTokenActive(pool.currentState, tx.FeeTokenID()) if err != nil { return fmt.Errorf("get token status failed %v", err) @@ -763,7 +773,7 @@ func (pool *TxPool) validateTx(tx *types.Transaction, local bool) error { return fmt.Errorf("failed to calculate L1 data fee, err: %w", err) } // Transactor should have enough funds to cover the costs - if tx.IsAltFeeTx() { + if tx.IsMorphTxWithAltFee() { if b := pool.currentState.GetBalance(from); b.Cmp(tx.Value()) < 0 { return ErrInsufficientValue } @@ -1261,6 +1271,23 @@ func (pool *TxPool) removeTx(hash common.Hash, outofbound bool) { } } +// removeMorphTxV1 removes all MorphTx V1 transactions from the pool. +// This is called when the jade fork is not active (e.g. after a reorg that +// rolls back past the fork time) to ensure no V1 transactions remain in the pool. +func (pool *TxPool) removeMorphTxV1() { + var toRemove []common.Hash + pool.all.Range(func(hash common.Hash, tx *types.Transaction, local bool) bool { + if tx.IsMorphTx() && tx.Version() == types.MorphTxVersion1 { + toRemove = append(toRemove, hash) + } + return true + }, true, true) + for _, hash := range toRemove { + log.Trace("Removing MorphTx V1 (jade fork not active)", "hash", hash) + pool.removeTx(hash, true) + } +} + // requestReset requests a pool reset to the new head block. // The returned channel is closed when the reset has occurred. func (pool *TxPool) requestReset(oldHead *types.Header, newHead *types.Header) chan struct{} { @@ -1533,6 +1560,12 @@ func (pool *TxPool) reset(oldHead, newHead *types.Header) { pool.eip1559 = pool.chainconfig.IsCurie(next) pool.shanghai = pool.chainconfig.IsShanghai(next) pool.eip7702 = pool.chainconfig.IsViridian(next, newHead.Time) + pool.jade = pool.chainconfig.IsJadeFork(newHead.Time) + + // Remove MorphTx V1 transactions if jade fork is not active (e.g. after reorg) + if !pool.jade { + pool.removeMorphTxV1() + } // Update current head pool.currentHead = next @@ -1617,7 +1650,16 @@ func (pool *TxPool) promoteExecutables(accounts []common.Address) []*types.Trans func (pool *TxPool) executableTxFilter(addr common.Address, costLimit *big.Int, altCostLimit map[uint16]*big.Int) func(tx *types.Transaction) bool { return func(tx *types.Transaction) bool { - if !tx.IsAltFeeTx() && (tx.Gas() > pool.currentMaxGas || tx.Cost().Cmp(costLimit) > 0) { + // Calculate cost based on transaction type + var txCost *big.Int + if tx.IsMorphTxWithAltFee() { + // MorphTx with alt fee is handled separately + txCost = nil + } else { + txCost = tx.Cost() + } + + if txCost != nil && (tx.Gas() > pool.currentMaxGas || txCost.Cmp(costLimit) > 0) { return true } if pool.chainconfig.Morph.FeeVaultEnabled() { @@ -1625,14 +1667,14 @@ func (pool *TxPool) executableTxFilter(addr common.Address, costLimit *big.Int, l1DataFee, err := fees.CalculateL1DataFee(tx, pool.currentState, pool.chainconfig, pool.currentHead) if err != nil { log.Error("Failed to calculate L1 data fee", "err", err, "tx", tx) - return false + return true } - if tx.IsAltFeeTx() { + if tx.IsMorphTxWithAltFee() { if altCostLimit[tx.FeeTokenID()] == nil { balance, err := pool.getBalanceFunc(pool.chain.CurrentBlock().Header(), pool.currentState, tx.FeeTokenID(), addr) if err != nil || balance == nil { log.Error("Failed to query balance", "err", err, "tx", tx) - return false + return true } // account cost limit altCostLimit[tx.FeeTokenID()] = balance @@ -1640,7 +1682,7 @@ func (pool *TxPool) executableTxFilter(addr common.Address, costLimit *big.Int, altAmount, err := fees.EthToAlt(pool.currentState, tx.FeeTokenID(), new(big.Int).Add(tx.GasFee(), l1DataFee)) if err != nil { log.Error("Failed to swap to erc20", "err", err, "tx", tx) - return false + return true } limit := altCostLimit[tx.FeeTokenID()] if tx.FeeLimit() != nil && tx.FeeLimit().Sign() > 0 { diff --git a/core/tx_pool_test.go b/core/tx_pool_test.go index 26bde89dc..432fc06b4 100644 --- a/core/tx_pool_test.go +++ b/core/tx_pool_test.go @@ -54,6 +54,12 @@ var ( // eip1559NoL1feeConfig is a chain config with EIP-1559 enabled at block 0 but not enabling L1fee. eip1559NoL1feeConfig *params.ChainConfig + + // morphTxConfig is a chain config with Emerald fork enabled (supports MorphTx). + morphTxConfig *params.ChainConfig + + // noEmeraldConfig is a chain config without Emerald fork (MorphTx not supported). + noEmeraldConfig *params.ChainConfig ) func init() { @@ -72,6 +78,21 @@ func init() { eip1559NoL1feeConfig = &cpy2 eip1559NoL1feeConfig.BerlinBlock = common.Big0 eip1559NoL1feeConfig.LondonBlock = common.Big0 + + // MorphTx config with Emerald and Jade fork enabled (supports MorphTx V0 and V1) + cpy3 := *params.TestChainConfig + morphTxConfig = &cpy3 + morphTxConfig.BerlinBlock = common.Big0 + morphTxConfig.LondonBlock = common.Big0 + morphTxConfig.EmeraldTime = new(uint64) // Enable Emerald fork at time 0 + morphTxConfig.JadeForkTime = new(uint64) // Enable Jade fork at time 0 + + // Config without Emerald fork (for testing MorphTx rejection) + cpy4 := *params.TestChainConfig + noEmeraldConfig = &cpy4 + noEmeraldConfig.BerlinBlock = common.Big0 + noEmeraldConfig.LondonBlock = common.Big0 + noEmeraldConfig.EmeraldTime = nil // Disable Emerald fork } type testBlockChain struct { @@ -130,6 +151,71 @@ func dynamicFeeTx(nonce uint64, gaslimit uint64, gasFee *big.Int, tip *big.Int, return tx } +// morphTxV0 creates a MorphTx Version 0 (legacy format with alt fee). +// V0 requires FeeTokenID > 0. +func morphTxV0(nonce uint64, gaslimit uint64, gasFee *big.Int, tip *big.Int, feeTokenID uint16, key *ecdsa.PrivateKey) *types.Transaction { + tx, _ := types.SignNewTx(key, types.LatestSignerForChainID(params.TestChainConfig.ChainID), &types.MorphTx{ + ChainID: params.TestChainConfig.ChainID, + Nonce: nonce, + GasTipCap: tip, + GasFeeCap: gasFee, + Gas: gaslimit, + To: &common.Address{}, + Value: big.NewInt(100), + Data: nil, + AccessList: nil, + FeeTokenID: feeTokenID, + FeeLimit: big.NewInt(1000000), + Version: types.MorphTxVersion0, + }) + return tx +} + +// morphTxV1 creates a MorphTx Version 1 (with Reference and Memo). +// V1 allows FeeTokenID = 0. +func morphTxV1(nonce uint64, gaslimit uint64, gasFee *big.Int, tip *big.Int, key *ecdsa.PrivateKey) *types.Transaction { + ref := common.HexToReference("0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef") + memo := []byte("test memo") + tx, _ := types.SignNewTx(key, types.LatestSignerForChainID(params.TestChainConfig.ChainID), &types.MorphTx{ + ChainID: params.TestChainConfig.ChainID, + Nonce: nonce, + GasTipCap: tip, + GasFeeCap: gasFee, + Gas: gaslimit, + To: &common.Address{}, + Value: big.NewInt(100), + Data: nil, + AccessList: nil, + FeeTokenID: 0, + FeeLimit: big.NewInt(0), + Version: types.MorphTxVersion1, + Reference: &ref, + Memo: &memo, + }) + return tx +} + +// morphTxV1WithMemo creates a MorphTx Version 1 with custom memo. +func morphTxV1WithMemo(nonce uint64, gaslimit uint64, gasFee *big.Int, tip *big.Int, memo []byte, key *ecdsa.PrivateKey) *types.Transaction { + ref := common.HexToReference("0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef") + tx, _ := types.SignNewTx(key, types.LatestSignerForChainID(params.TestChainConfig.ChainID), &types.MorphTx{ + ChainID: params.TestChainConfig.ChainID, + Nonce: nonce, + GasTipCap: tip, + GasFeeCap: gasFee, + Gas: gaslimit, + To: &common.Address{}, + Value: big.NewInt(100), + Data: nil, + AccessList: nil, + FeeTokenID: 0, + Version: types.MorphTxVersion1, + Reference: &ref, + Memo: &memo, + }) + return tx +} + func setupTxPool() (*TxPool, *ecdsa.PrivateKey) { return setupTxPoolWithConfig(params.TestChainConfig) } @@ -2602,3 +2688,488 @@ func TestPoolPending(t *testing.T) { maxAccounts := 10 assert.Len(t, pool.PendingWithMax(nil, nil, maxAccounts), maxAccounts) } + +// ==================== MorphTx Tests ==================== + +// TestMorphTxValidation tests MorphTx validation in the transaction pool. +func TestMorphTxValidation(t *testing.T) { + t.Parallel() + + t.Run("MemoTooLong", func(t *testing.T) { + t.Parallel() + + pool, key := setupTxPoolWithConfig(morphTxConfig) + defer pool.Stop() + + account := crypto.PubkeyToAddress(key.PublicKey) + testAddBalance(pool, account, big.NewInt(1000000000)) + + // Memo exceeds max length (64 bytes) + longMemo := make([]byte, common.MaxMemoLength+1) + tx := morphTxV1WithMemo(0, 100000, big.NewInt(10), big.NewInt(1), longMemo, key) + if err := pool.AddRemote(tx); !errors.Is(err, types.ErrMemoTooLong) { + t.Errorf("expected ErrMemoTooLong, got %v", err) + } + }) + + t.Run("MemoMaxLength", func(t *testing.T) { + t.Parallel() + + pool, key := setupTxPoolWithConfig(morphTxConfig) + defer pool.Stop() + + account := crypto.PubkeyToAddress(key.PublicKey) + testAddBalance(pool, account, big.NewInt(1000000000)) + + // Memo at max length (64 bytes) should be accepted + maxMemo := make([]byte, common.MaxMemoLength) + tx := morphTxV1WithMemo(0, 100000, big.NewInt(10), big.NewInt(1), maxMemo, key) + if err := pool.AddRemote(tx); err != nil { + t.Errorf("expected no error for max memo length, got %v", err) + } + }) + + t.Run("V0RequiresFeeTokenID", func(t *testing.T) { + t.Parallel() + + pool, key := setupTxPoolWithConfig(morphTxConfig) + defer pool.Stop() + + account := crypto.PubkeyToAddress(key.PublicKey) + testAddBalance(pool, account, big.NewInt(1000000000)) + + // V0 with FeeTokenID = 0 should be rejected + tx := morphTxV0(0, 100000, big.NewInt(10), big.NewInt(1), 0, key) + if err := pool.AddRemote(tx); !errors.Is(err, types.ErrMorphTxV0IllegalExtraParams) { + t.Errorf("expected ErrMorphTxV0IllegalExtraParams, got %v", err) + } + }) + + t.Run("V0WithFeeTokenID", func(t *testing.T) { + t.Parallel() + + pool, key := setupTxPoolWithConfig(morphTxConfig) + defer pool.Stop() + + account := crypto.PubkeyToAddress(key.PublicKey) + testAddBalance(pool, account, big.NewInt(1000000000)) + + // V0 with FeeTokenID > 0 - this will fail due to token not being active, + // but it should pass version validation + tx := morphTxV0(0, 100000, big.NewInt(10), big.NewInt(1), 1, key) + err := pool.AddRemote(tx) + // Should not be version error (but may be token error) + if errors.Is(err, types.ErrMorphTxV0IllegalExtraParams) { + t.Errorf("V0 with FeeTokenID > 0 should pass version validation, got %v", err) + } + }) + + t.Run("UnsupportedVersion", func(t *testing.T) { + t.Parallel() + + pool, key := setupTxPoolWithConfig(morphTxConfig) + defer pool.Stop() + + account := crypto.PubkeyToAddress(key.PublicKey) + testAddBalance(pool, account, big.NewInt(1000000000)) + + // Create a MorphTx with unsupported version + ref := common.HexToReference("0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef") + memo := []byte("test") + tx, _ := types.SignNewTx(key, types.LatestSignerForChainID(params.TestChainConfig.ChainID), &types.MorphTx{ + ChainID: params.TestChainConfig.ChainID, + Nonce: 0, + GasTipCap: big.NewInt(1), + GasFeeCap: big.NewInt(10), + Gas: 100000, + To: &common.Address{}, + Value: big.NewInt(100), + FeeTokenID: 0, + Version: 2, // Unsupported version + Reference: &ref, + Memo: &memo, + }) + if err := pool.AddRemote(tx); !errors.Is(err, types.ErrMorphTxUnsupportedVersion) { + t.Errorf("expected ErrMorphTxUnsupportedVersion, got %v", err) + } + }) + + t.Run("V0WithReference", func(t *testing.T) { + t.Parallel() + + pool, key := setupTxPoolWithConfig(morphTxConfig) + defer pool.Stop() + + account := crypto.PubkeyToAddress(key.PublicKey) + testAddBalance(pool, account, big.NewInt(1000000000)) + + // V0 with Reference should be rejected + ref := common.HexToReference("0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef") + tx, _ := types.SignNewTx(key, types.LatestSignerForChainID(params.TestChainConfig.ChainID), &types.MorphTx{ + ChainID: params.TestChainConfig.ChainID, + Nonce: 0, + GasTipCap: big.NewInt(1), + GasFeeCap: big.NewInt(10), + Gas: 100000, + To: &common.Address{}, + Value: big.NewInt(100), + FeeTokenID: 1, + Version: types.MorphTxVersion0, + Reference: &ref, + }) + if err := pool.AddRemote(tx); !errors.Is(err, types.ErrMorphTxV0IllegalExtraParams) { + t.Errorf("expected ErrMorphTxV0IllegalExtraParams, got %v", err) + } + }) + + t.Run("V0WithMemo", func(t *testing.T) { + t.Parallel() + + pool, key := setupTxPoolWithConfig(morphTxConfig) + defer pool.Stop() + + account := crypto.PubkeyToAddress(key.PublicKey) + testAddBalance(pool, account, big.NewInt(1000000000)) + + // V0 with Memo should be rejected + memo := []byte("test memo") + tx, _ := types.SignNewTx(key, types.LatestSignerForChainID(params.TestChainConfig.ChainID), &types.MorphTx{ + ChainID: params.TestChainConfig.ChainID, + Nonce: 0, + GasTipCap: big.NewInt(1), + GasFeeCap: big.NewInt(10), + Gas: 100000, + To: &common.Address{}, + Value: big.NewInt(100), + FeeTokenID: 1, + Version: types.MorphTxVersion0, + Memo: &memo, + }) + if err := pool.AddRemote(tx); !errors.Is(err, types.ErrMorphTxV0IllegalExtraParams) { + t.Errorf("expected ErrMorphTxV0IllegalExtraParams, got %v", err) + } + }) + + t.Run("V1WithFeeLimitButNoFeeToken", func(t *testing.T) { + t.Parallel() + + pool, key := setupTxPoolWithConfig(morphTxConfig) + defer pool.Stop() + + account := crypto.PubkeyToAddress(key.PublicKey) + testAddBalance(pool, account, big.NewInt(1000000000)) + + // V1 with FeeLimit but FeeTokenID=0 should be rejected + ref := common.HexToReference("0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef") + memo := []byte("test memo") + tx, _ := types.SignNewTx(key, types.LatestSignerForChainID(params.TestChainConfig.ChainID), &types.MorphTx{ + ChainID: params.TestChainConfig.ChainID, + Nonce: 0, + GasTipCap: big.NewInt(1), + GasFeeCap: big.NewInt(10), + Gas: 100000, + To: &common.Address{}, + Value: big.NewInt(100), + FeeTokenID: 0, + FeeLimit: big.NewInt(1000000), // FeeLimit set but no FeeToken + Version: types.MorphTxVersion1, + Reference: &ref, + Memo: &memo, + }) + if err := pool.AddRemote(tx); !errors.Is(err, types.ErrMorphTxV1IllegalExtraParams) { + t.Errorf("expected ErrMorphTxV1IllegalExtraParams, got %v", err) + } + }) +} + +// TestMorphTxEIP1559Activation tests that MorphTx is rejected before Emerald fork. +func TestMorphTxEIP1559Activation(t *testing.T) { + t.Parallel() + + t.Run("RejectBeforeEmerald", func(t *testing.T) { + t.Parallel() + + pool, key := setupTxPoolWithConfig(noEmeraldConfig) + defer pool.Stop() + + account := crypto.PubkeyToAddress(key.PublicKey) + testAddBalance(pool, account, big.NewInt(1000000000)) + + tx := morphTxV1(0, 100000, big.NewInt(10), big.NewInt(1), key) + err := pool.AddRemote(tx) + // Before Emerald, the signer doesn't support MorphTx, so we get either + // ErrTxTypeNotSupported or invalid sender error (signer check happens first) + if err == nil { + t.Error("expected MorphTx to be rejected before Emerald") + } + }) + + t.Run("AcceptAfterEmerald", func(t *testing.T) { + t.Parallel() + + pool, key := setupTxPoolWithConfig(morphTxConfig) + defer pool.Stop() + + account := crypto.PubkeyToAddress(key.PublicKey) + testAddBalance(pool, account, big.NewInt(1000000000)) + + tx := morphTxV1(0, 100000, big.NewInt(10), big.NewInt(1), key) + if err := pool.AddRemote(tx); err != nil { + t.Errorf("expected MorphTx to be accepted after Emerald, got %v", err) + } + }) +} + +// TestMorphTxPoolManagement tests MorphTx behavior in transaction pool. +func TestMorphTxPoolManagement(t *testing.T) { + t.Parallel() + + t.Run("PendingAndQueued", func(t *testing.T) { + t.Parallel() + + pool, key := setupTxPoolWithConfig(morphTxConfig) + defer pool.Stop() + + account := crypto.PubkeyToAddress(key.PublicKey) + testAddBalance(pool, account, big.NewInt(1000000000)) + + // Add executable MorphTx (nonce 0) + tx0 := morphTxV1(0, 100000, big.NewInt(10), big.NewInt(1), key) + if err := pool.addRemoteSync(tx0); err != nil { + t.Fatalf("failed to add tx0: %v", err) + } + + // Add queued MorphTx (nonce 2, gap at nonce 1) + tx2 := morphTxV1(2, 100000, big.NewInt(10), big.NewInt(1), key) + if err := pool.addRemoteSync(tx2); err != nil { + t.Fatalf("failed to add tx2: %v", err) + } + + pending, queued := pool.Stats() + if pending != 1 { + t.Errorf("pending mismatch: have %d, want 1", pending) + } + if queued != 1 { + t.Errorf("queued mismatch: have %d, want 1", queued) + } + + // Fill the gap + tx1 := morphTxV1(1, 100000, big.NewInt(10), big.NewInt(1), key) + if err := pool.addRemoteSync(tx1); err != nil { + t.Fatalf("failed to add tx1: %v", err) + } + + pending, queued = pool.Stats() + if pending != 3 { + t.Errorf("after gap fill pending mismatch: have %d, want 3", pending) + } + if queued != 0 { + t.Errorf("after gap fill queued mismatch: have %d, want 0", queued) + } + }) + + t.Run("Replacement", func(t *testing.T) { + t.Parallel() + + pool, key := setupTxPoolWithConfig(morphTxConfig) + defer pool.Stop() + + account := crypto.PubkeyToAddress(key.PublicKey) + testAddBalance(pool, account, big.NewInt(1000000000)) + + // Add initial MorphTx + tx1 := morphTxV1(0, 100000, big.NewInt(10), big.NewInt(1), key) + if err := pool.addRemoteSync(tx1); err != nil { + t.Fatalf("failed to add tx1: %v", err) + } + + // Try to replace with same gas price but different gas limit (should fail) + tx2 := morphTxV1(0, 200000, big.NewInt(10), big.NewInt(1), key) + if err := pool.addRemoteSync(tx2); err == nil { + t.Error("expected rejection for same-price replacement") + } + + // Replace with higher gas price (10% bump required) + tx3 := morphTxV1(0, 100000, big.NewInt(12), big.NewInt(2), key) + if err := pool.addRemoteSync(tx3); err != nil { + t.Errorf("expected acceptance for higher-price replacement, got %v", err) + } + + pending, _ := pool.Stats() + if pending != 1 { + t.Errorf("pending mismatch after replacement: have %d, want 1", pending) + } + }) +} + +// TestMorphTxMixedTransactions tests pool with mixed transaction types. +func TestMorphTxMixedTransactions(t *testing.T) { + t.Parallel() + + pool, key := setupTxPoolWithConfig(morphTxConfig) + defer pool.Stop() + + account := crypto.PubkeyToAddress(key.PublicKey) + testAddBalance(pool, account, big.NewInt(10000000000)) + + // Add legacy transaction + tx0 := transaction(0, 100000, key) + if err := pool.addRemoteSync(tx0); err != nil { + t.Fatalf("failed to add legacy tx: %v", err) + } + + // Add dynamic fee transaction + tx1 := dynamicFeeTx(1, 100000, big.NewInt(10), big.NewInt(1), key) + if err := pool.addRemoteSync(tx1); err != nil { + t.Fatalf("failed to add dynamic fee tx: %v", err) + } + + // Add MorphTx V1 + tx2 := morphTxV1(2, 100000, big.NewInt(10), big.NewInt(1), key) + if err := pool.addRemoteSync(tx2); err != nil { + t.Fatalf("failed to add MorphTx V1: %v", err) + } + + pending, queued := pool.Stats() + if pending != 3 { + t.Errorf("pending mismatch: have %d, want 3", pending) + } + if queued != 0 { + t.Errorf("queued mismatch: have %d, want 0", queued) + } + + if err := validateTxPoolInternals(pool); err != nil { + t.Fatalf("pool internal state corrupted: %v", err) + } +} + +// TestMorphTxAccessors tests MorphTx accessor methods. +func TestMorphTxAccessors(t *testing.T) { + t.Parallel() + + key, _ := crypto.GenerateKey() + + t.Run("V0Accessors", func(t *testing.T) { + tx := morphTxV0(0, 100000, big.NewInt(10), big.NewInt(1), 1, key) + if !tx.IsMorphTx() { + t.Error("IsMorphTx should return true") + } + if !tx.IsMorphTxWithAltFee() { + t.Error("IsMorphTxWithAltFee should return true for FeeTokenID > 0") + } + if tx.Version() != types.MorphTxVersion0 { + t.Errorf("Version mismatch: have %d, want %d", tx.Version(), types.MorphTxVersion0) + } + if tx.FeeTokenID() != 1 { + t.Errorf("FeeTokenID mismatch: have %d, want 1", tx.FeeTokenID()) + } + }) + + t.Run("V1Accessors", func(t *testing.T) { + tx := morphTxV1(0, 100000, big.NewInt(10), big.NewInt(1), key) + if !tx.IsMorphTx() { + t.Error("IsMorphTx should return true") + } + if tx.IsMorphTxWithAltFee() { + t.Error("IsMorphTxWithAltFee should return false for FeeTokenID = 0") + } + if tx.Version() != types.MorphTxVersion1 { + t.Errorf("Version mismatch: have %d, want %d", tx.Version(), types.MorphTxVersion1) + } + if tx.Reference() == nil { + t.Error("Reference should not be nil") + } + if tx.Memo() == nil { + t.Error("Memo should not be nil") + } + }) +} + +// TestMorphTxV1JadeForkGating tests that MorphTx V1 is rejected before the jade +// fork and accepted after the jade fork, while V0 is always accepted when Emerald +// is active. +func TestMorphTxV1JadeForkGating(t *testing.T) { + t.Parallel() + + // Config with Emerald enabled but Jade NOT enabled (no JadeForkTime). + preJadeConfig := func() *params.ChainConfig { + cpy := *params.TestChainConfig + cfg := &cpy + cfg.BerlinBlock = common.Big0 + cfg.LondonBlock = common.Big0 + cfg.EmeraldTime = new(uint64) // Emerald at time 0 + cfg.JadeForkTime = nil // Jade not active + return cfg + }() + + t.Run("V1RejectedBeforeJadeFork", func(t *testing.T) { + t.Parallel() + pool, key := setupTxPoolWithConfig(preJadeConfig) + defer pool.Stop() + + account := crypto.PubkeyToAddress(key.PublicKey) + testAddBalance(pool, account, big.NewInt(1000000000)) + + tx := morphTxV1(0, 100000, big.NewInt(10), big.NewInt(1), key) + if err := pool.AddRemote(tx); !errors.Is(err, types.ErrMorphTxV1NotYetActive) { + t.Errorf("expected ErrMorphTxV1NotYetActive, got %v", err) + } + }) + + t.Run("V0AcceptedBeforeJadeFork", func(t *testing.T) { + t.Parallel() + pool, key := setupTxPoolWithConfig(preJadeConfig) + defer pool.Stop() + + account := crypto.PubkeyToAddress(key.PublicKey) + testAddBalance(pool, account, big.NewInt(1000000000)) + + // V0 with FeeTokenID > 0 should still pass version validation + tx := morphTxV0(0, 100000, big.NewInt(10), big.NewInt(1), 1, key) + err := pool.AddRemote(tx) + // Should NOT be the jade fork error or version error + if errors.Is(err, types.ErrMorphTxV1NotYetActive) { + t.Errorf("V0 should not be rejected by jade fork check, got %v", err) + } + if errors.Is(err, types.ErrMorphTxV0IllegalExtraParams) { + t.Errorf("V0 with FeeTokenID > 0 should pass version validation, got %v", err) + } + }) + + t.Run("V1AcceptedAfterJadeFork", func(t *testing.T) { + t.Parallel() + // Use morphTxConfig which has JadeForkTime enabled at time 0 + pool, key := setupTxPoolWithConfig(morphTxConfig) + defer pool.Stop() + + account := crypto.PubkeyToAddress(key.PublicKey) + testAddBalance(pool, account, big.NewInt(1000000000)) + + tx := morphTxV1(0, 100000, big.NewInt(10), big.NewInt(1), key) + if err := pool.AddRemote(tx); err != nil { + t.Errorf("V1 should be accepted after jade fork, got %v", err) + } + }) + + t.Run("V0AndV1BothAcceptedAfterJadeFork", func(t *testing.T) { + t.Parallel() + pool, key := setupTxPoolWithConfig(morphTxConfig) + defer pool.Stop() + + account := crypto.PubkeyToAddress(key.PublicKey) + testAddBalance(pool, account, big.NewInt(1000000000)) + + // V1 should be accepted + txV1 := morphTxV1(0, 100000, big.NewInt(10), big.NewInt(1), key) + if err := pool.AddRemote(txV1); err != nil { + t.Errorf("V1 should be accepted after jade fork, got %v", err) + } + + // V0 with FeeTokenID > 0 should also pass version validation + txV0 := morphTxV0(1, 100000, big.NewInt(10), big.NewInt(1), 1, key) + err := pool.AddRemote(txV0) + if errors.Is(err, types.ErrMorphTxV1NotYetActive) || errors.Is(err, types.ErrMorphTxV0IllegalExtraParams) { + t.Errorf("V0 should be accepted after jade fork, got %v", err) + } + }) +} diff --git a/core/types/alt_fee_tx.go b/core/types/alt_fee_tx.go deleted file mode 100644 index 28c06dfd3..000000000 --- a/core/types/alt_fee_tx.go +++ /dev/null @@ -1,149 +0,0 @@ -package types - -import ( - "bytes" - "math/big" - - "github.com/morph-l2/go-ethereum/common" - "github.com/morph-l2/go-ethereum/rlp" -) - -// Copyright 2021 The go-ethereum Authors -// This file is part of the go-ethereum library. -// -// The go-ethereum library is free software: you can redistribute it and/or modify -// it under the terms of the GNU Lesser General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// The go-ethereum library is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Lesser General Public License for more details. -// -// You should have received a copy of the GNU Lesser General Public License -// along with the go-ethereum library. If not, see . - -type AltFeeTx struct { - ChainID *big.Int - Nonce uint64 - GasTipCap *big.Int - GasFeeCap *big.Int - Gas uint64 - To *common.Address `rlp:"nil"` // nil means contract creation - Value *big.Int - Data []byte - AccessList AccessList - - FeeTokenID uint16 - FeeLimit *big.Int - - // Signature values - V *big.Int `json:"v" gencodec:"required"` - R *big.Int `json:"r" gencodec:"required"` - S *big.Int `json:"s" gencodec:"required"` -} - -// copy creates a deep copy of the transaction data and initializes all fields. -func (tx *AltFeeTx) copy() TxData { - cpy := &AltFeeTx{ - Nonce: tx.Nonce, - To: copyAddressPtr(tx.To), - Data: common.CopyBytes(tx.Data), - Gas: tx.Gas, - FeeTokenID: tx.FeeTokenID, - // These are copied below. - AccessList: make(AccessList, len(tx.AccessList)), - Value: new(big.Int), - ChainID: new(big.Int), - GasTipCap: new(big.Int), - GasFeeCap: new(big.Int), - V: new(big.Int), - R: new(big.Int), - S: new(big.Int), - } - copy(cpy.AccessList, tx.AccessList) - if tx.Value != nil { - cpy.Value.Set(tx.Value) - } - if tx.ChainID != nil { - cpy.ChainID.Set(tx.ChainID) - } - if tx.GasTipCap != nil { - cpy.GasTipCap.Set(tx.GasTipCap) - } - if tx.GasFeeCap != nil { - cpy.GasFeeCap.Set(tx.GasFeeCap) - } - if tx.FeeLimit != nil { - cpy.FeeLimit = new(big.Int).Set(tx.FeeLimit) - } - if tx.V != nil { - cpy.V.Set(tx.V) - } - if tx.R != nil { - cpy.R.Set(tx.R) - } - if tx.S != nil { - cpy.S.Set(tx.S) - } - return cpy -} - -// accessors for innerTx. -func (tx *AltFeeTx) txType() byte { return AltFeeTxType } -func (tx *AltFeeTx) chainID() *big.Int { return tx.ChainID } -func (tx *AltFeeTx) accessList() AccessList { return tx.AccessList } -func (tx *AltFeeTx) data() []byte { return tx.Data } -func (tx *AltFeeTx) gas() uint64 { return tx.Gas } -func (tx *AltFeeTx) gasFeeCap() *big.Int { return tx.GasFeeCap } -func (tx *AltFeeTx) gasTipCap() *big.Int { return tx.GasTipCap } -func (tx *AltFeeTx) gasPrice() *big.Int { return tx.GasFeeCap } -func (tx *AltFeeTx) value() *big.Int { return tx.Value } -func (tx *AltFeeTx) nonce() uint64 { return tx.Nonce } -func (tx *AltFeeTx) to() *common.Address { return tx.To } - -func (tx *AltFeeTx) effectiveGasPrice(dst *big.Int, baseFee *big.Int) *big.Int { - if baseFee == nil { - return dst.Set(tx.GasFeeCap) - } - tip := dst.Sub(tx.GasFeeCap, baseFee) - if tip.Cmp(tx.GasTipCap) > 0 { - tip.Set(tx.GasTipCap) - } - return tip.Add(tip, baseFee) -} - -func (tx *AltFeeTx) rawSignatureValues() (v, r, s *big.Int) { - return tx.V, tx.R, tx.S -} - -func (tx *AltFeeTx) setSignatureValues(chainID, v, r, s *big.Int) { - tx.ChainID, tx.V, tx.R, tx.S = chainID, v, r, s -} - -func (tx *AltFeeTx) encode(b *bytes.Buffer) error { - return rlp.Encode(b, tx) -} - -func (tx *AltFeeTx) decode(input []byte) error { - return rlp.DecodeBytes(input, tx) -} - -func (tx *AltFeeTx) sigHash(chainID *big.Int) common.Hash { - return prefixedRlpHash( - AltFeeTxType, - []any{ - chainID, - tx.Nonce, - tx.GasTipCap, - tx.GasFeeCap, - tx.Gas, - tx.To, - tx.Value, - tx.Data, - tx.AccessList, - tx.FeeTokenID, - tx.FeeLimit, - }) -} diff --git a/core/types/gen_receipt_json.go b/core/types/gen_receipt_json.go index 34b44dd3c..932c7fa1d 100644 --- a/core/types/gen_receipt_json.go +++ b/core/types/gen_receipt_json.go @@ -16,27 +16,30 @@ var _ = (*receiptMarshaling)(nil) // MarshalJSON marshals as JSON. func (r Receipt) MarshalJSON() ([]byte, error) { type Receipt struct { - Type hexutil.Uint64 `json:"type,omitempty"` - PostState hexutil.Bytes `json:"root"` - Status hexutil.Uint64 `json:"status"` - CumulativeGasUsed hexutil.Uint64 `json:"cumulativeGasUsed" gencodec:"required"` - Bloom Bloom `json:"logsBloom" gencodec:"required"` - Logs []*Log `json:"logs" gencodec:"required"` - TxHash common.Hash `json:"transactionHash" gencodec:"required"` - ContractAddress common.Address `json:"contractAddress"` - GasUsed hexutil.Uint64 `json:"gasUsed" gencodec:"required"` - EffectiveGasPrice *hexutil.Big `json:"effectiveGasPrice"` - BlobGasUsed hexutil.Uint64 `json:"blobGasUsed,omitempty"` - BlobGasPrice *hexutil.Big `json:"blobGasPrice,omitempty"` - BlockHash common.Hash `json:"blockHash,omitempty"` - BlockNumber *hexutil.Big `json:"blockNumber,omitempty"` - TransactionIndex hexutil.Uint `json:"transactionIndex"` - ReturnValue []byte `json:"returnValue,omitempty"` - L1Fee *hexutil.Big `json:"l1Fee,omitempty"` - FeeTokenID *uint16 `json:"feeTokenID,omitempty"` - FeeRate *hexutil.Big `json:"feeRate,omitempty"` - TokenScale *hexutil.Big `json:"tokenScale,omitempty"` - FeeLimit *hexutil.Big `json:"feeLimit,omitempty"` + Type hexutil.Uint64 `json:"type,omitempty"` + PostState hexutil.Bytes `json:"root"` + Status hexutil.Uint64 `json:"status"` + CumulativeGasUsed hexutil.Uint64 `json:"cumulativeGasUsed" gencodec:"required"` + Bloom Bloom `json:"logsBloom" gencodec:"required"` + Logs []*Log `json:"logs" gencodec:"required"` + TxHash common.Hash `json:"transactionHash" gencodec:"required"` + ContractAddress common.Address `json:"contractAddress"` + GasUsed hexutil.Uint64 `json:"gasUsed" gencodec:"required"` + EffectiveGasPrice *hexutil.Big `json:"effectiveGasPrice"` + BlobGasUsed hexutil.Uint64 `json:"blobGasUsed,omitempty"` + BlobGasPrice *hexutil.Big `json:"blobGasPrice,omitempty"` + BlockHash common.Hash `json:"blockHash,omitempty"` + BlockNumber *hexutil.Big `json:"blockNumber,omitempty"` + TransactionIndex hexutil.Uint `json:"transactionIndex"` + ReturnValue []byte `json:"returnValue,omitempty"` + L1Fee *hexutil.Big `json:"l1Fee,omitempty"` + FeeTokenID *hexutil.Uint16 `json:"feeTokenID,omitempty"` + FeeRate *hexutil.Big `json:"feeRate,omitempty"` + TokenScale *hexutil.Big `json:"tokenScale,omitempty"` + FeeLimit *hexutil.Big `json:"feeLimit,omitempty"` + Version hexutil.Uint64 `json:"version,omitempty"` + Reference *common.Reference `json:"reference,omitempty"` + Memo *hexutil.Bytes `json:"memo,omitempty"` } var enc Receipt enc.Type = hexutil.Uint64(r.Type) @@ -56,37 +59,43 @@ func (r Receipt) MarshalJSON() ([]byte, error) { enc.TransactionIndex = hexutil.Uint(r.TransactionIndex) enc.ReturnValue = r.ReturnValue enc.L1Fee = (*hexutil.Big)(r.L1Fee) - enc.FeeTokenID = r.FeeTokenID + enc.FeeTokenID = (*hexutil.Uint16)(r.FeeTokenID) enc.FeeRate = (*hexutil.Big)(r.FeeRate) enc.TokenScale = (*hexutil.Big)(r.TokenScale) enc.FeeLimit = (*hexutil.Big)(r.FeeLimit) + enc.Version = hexutil.Uint64(r.Version) + enc.Reference = r.Reference + enc.Memo = (*hexutil.Bytes)(r.Memo) return json.Marshal(&enc) } // UnmarshalJSON unmarshals from JSON. func (r *Receipt) UnmarshalJSON(input []byte) error { type Receipt struct { - Type *hexutil.Uint64 `json:"type,omitempty"` - PostState *hexutil.Bytes `json:"root"` - Status *hexutil.Uint64 `json:"status"` - CumulativeGasUsed *hexutil.Uint64 `json:"cumulativeGasUsed" gencodec:"required"` - Bloom *Bloom `json:"logsBloom" gencodec:"required"` - Logs []*Log `json:"logs" gencodec:"required"` - TxHash *common.Hash `json:"transactionHash" gencodec:"required"` - ContractAddress *common.Address `json:"contractAddress"` - GasUsed *hexutil.Uint64 `json:"gasUsed" gencodec:"required"` - EffectiveGasPrice *hexutil.Big `json:"effectiveGasPrice"` - BlobGasUsed *hexutil.Uint64 `json:"blobGasUsed,omitempty"` - BlobGasPrice *hexutil.Big `json:"blobGasPrice,omitempty"` - BlockHash *common.Hash `json:"blockHash,omitempty"` - BlockNumber *hexutil.Big `json:"blockNumber,omitempty"` - TransactionIndex *hexutil.Uint `json:"transactionIndex"` - ReturnValue []byte `json:"returnValue,omitempty"` - L1Fee *hexutil.Big `json:"l1Fee,omitempty"` - FeeTokenID *hexutil.Uint16 `json:"feeTokenID,omitempty"` - FeeRate *hexutil.Big `json:"feeRate,omitempty"` - TokenScale *hexutil.Big `json:"tokenScale,omitempty"` - FeeLimit *hexutil.Big `json:"feeLimit,omitempty"` + Type *hexutil.Uint64 `json:"type,omitempty"` + PostState *hexutil.Bytes `json:"root"` + Status *hexutil.Uint64 `json:"status"` + CumulativeGasUsed *hexutil.Uint64 `json:"cumulativeGasUsed" gencodec:"required"` + Bloom *Bloom `json:"logsBloom" gencodec:"required"` + Logs []*Log `json:"logs" gencodec:"required"` + TxHash *common.Hash `json:"transactionHash" gencodec:"required"` + ContractAddress *common.Address `json:"contractAddress"` + GasUsed *hexutil.Uint64 `json:"gasUsed" gencodec:"required"` + EffectiveGasPrice *hexutil.Big `json:"effectiveGasPrice"` + BlobGasUsed *hexutil.Uint64 `json:"blobGasUsed,omitempty"` + BlobGasPrice *hexutil.Big `json:"blobGasPrice,omitempty"` + BlockHash *common.Hash `json:"blockHash,omitempty"` + BlockNumber *hexutil.Big `json:"blockNumber,omitempty"` + TransactionIndex *hexutil.Uint `json:"transactionIndex"` + ReturnValue []byte `json:"returnValue,omitempty"` + L1Fee *hexutil.Big `json:"l1Fee,omitempty"` + FeeTokenID *hexutil.Uint16 `json:"feeTokenID,omitempty"` + FeeRate *hexutil.Big `json:"feeRate,omitempty"` + TokenScale *hexutil.Big `json:"tokenScale,omitempty"` + FeeLimit *hexutil.Big `json:"feeLimit,omitempty"` + Version *hexutil.Uint64 `json:"version,omitempty"` + Reference *common.Reference `json:"reference,omitempty"` + Memo *hexutil.Bytes `json:"memo,omitempty"` } var dec Receipt if err := json.Unmarshal(input, &dec); err != nil { @@ -149,8 +158,7 @@ func (r *Receipt) UnmarshalJSON(input []byte) error { r.L1Fee = (*big.Int)(dec.L1Fee) } if dec.FeeTokenID != nil { - tokenID := uint16(*dec.FeeTokenID) - r.FeeTokenID = &tokenID + r.FeeTokenID = (*uint16)(dec.FeeTokenID) } if dec.FeeRate != nil { r.FeeRate = (*big.Int)(dec.FeeRate) @@ -161,6 +169,14 @@ func (r *Receipt) UnmarshalJSON(input []byte) error { if dec.FeeLimit != nil { r.FeeLimit = (*big.Int)(dec.FeeLimit) } - + if dec.Version != nil { + r.Version = uint8(*dec.Version) + } + if dec.Reference != nil { + r.Reference = dec.Reference + } + if dec.Memo != nil { + r.Memo = (*[]byte)(dec.Memo) + } return nil } diff --git a/core/types/l2trace.go b/core/types/l2trace.go index decb681f7..80f620b85 100644 --- a/core/types/l2trace.go +++ b/core/types/l2trace.go @@ -49,14 +49,17 @@ type StorageTrace struct { // while replaying a transaction in debug mode as well as transaction // execution status, the amount of gas used and the return value type ExecutionResult struct { - L1DataFee *hexutil.Big `json:"l1DataFee,omitempty"` - FeeTokenID *uint16 `json:"feeTokenID,omitempty"` - FeeRate *hexutil.Big `json:"feeRate,omitempty"` - TokenScale *hexutil.Big `json:"tokenScale,omitempty"` - FeeLimit *hexutil.Big `json:"feeLimit,omitempty"` - Gas uint64 `json:"gas"` - Failed bool `json:"failed"` - ReturnValue string `json:"returnValue"` + L1DataFee *hexutil.Big `json:"l1DataFee,omitempty"` + FeeTokenID *uint16 `json:"feeTokenID,omitempty"` + FeeRate *hexutil.Big `json:"feeRate,omitempty"` + TokenScale *hexutil.Big `json:"tokenScale,omitempty"` + FeeLimit *hexutil.Big `json:"feeLimit,omitempty"` + Version *uint8 `json:"version,omitempty"` + Reference *common.Reference `json:"reference,omitempty"` + Memo *hexutil.Bytes `json:"memo,omitempty"` + Gas uint64 `json:"gas"` + Failed bool `json:"failed"` + ReturnValue string `json:"returnValue"` // Sender's account state (before Tx) From *AccountWrapper `json:"from,omitempty"` // Receiver's account state (before Tx) @@ -133,6 +136,9 @@ type TransactionData struct { GasFeeCap *hexutil.Big `json:"gasFeeCap"` FeeTokenID *uint16 `json:"feeTokenID,omitempty"` FeeLimit *hexutil.Big `json:"feeLimit,omitempty"` + Version uint8 `json:"version,omitempty"` + Reference *common.Reference `json:"reference,omitempty"` + Memo *hexutil.Bytes `json:"memo,omitempty"` From common.Address `json:"from"` To *common.Address `json:"to"` ChainId *hexutil.Big `json:"chainId"` @@ -188,8 +194,8 @@ func NewTransactionData(tx *Transaction, blockNumber uint64, blockTime uint64, c S: (*hexutil.Big)(s), } - // Set FeeTokenID and FeeLimit for AltFeeTx - if tx.Type() == AltFeeTxType { + // Set FeeTokenID and FeeLimit for MorphTx + if tx.Type() == MorphTxType { feeTokenID := tx.FeeTokenID() if feeTokenID != 0 { result.FeeTokenID = &feeTokenID @@ -197,6 +203,15 @@ func NewTransactionData(tx *Transaction, blockNumber uint64, blockTime uint64, c if feeLimit := tx.FeeLimit(); feeLimit != nil && feeLimit.Sign() > 0 { result.FeeLimit = (*hexutil.Big)(feeLimit) } + // Only include V1 fields (version, reference, memo) for V1+ transactions + if tx.Version() >= MorphTxVersion1 { + result.Version = tx.Version() + result.Reference = tx.Reference() + if tx.Memo() != nil { + memo := hexutil.Bytes(*tx.Memo()) + result.Memo = &memo + } + } } return result diff --git a/core/types/morph_tx.go b/core/types/morph_tx.go new file mode 100644 index 000000000..d0e7a906b --- /dev/null +++ b/core/types/morph_tx.go @@ -0,0 +1,387 @@ +package types + +import ( + "bytes" + "errors" + "io" + "math/big" + "strconv" + + "github.com/morph-l2/go-ethereum/common" + "github.com/morph-l2/go-ethereum/rlp" +) + +// Copyright 2021 The go-ethereum Authors +// This file is part of the go-ethereum library. +// +// The go-ethereum library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The go-ethereum library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see . + +// MorphTx version constants +const ( + // MorphTxVersion0 is the original format without Version, Reference, Memo fields + MorphTxVersion0 = uint8(0) + // MorphTxVersion1 includes Version, Reference, Memo fields + MorphTxVersion1 = uint8(1) +) + +type MorphTx struct { + ChainID *big.Int + Nonce uint64 + GasTipCap *big.Int + GasFeeCap *big.Int + Gas uint64 + To *common.Address `rlp:"nil"` // nil means contract creation + Value *big.Int + Data []byte + AccessList AccessList + + Version uint8 // version of morph tx (0 = legacy, 1 = with reference/memo) + FeeTokenID uint16 // ERC20 token ID for fee payment (0 = ETH) + FeeLimit *big.Int // maximum fee in token units (optional) + Reference *common.Reference // reference key for the transaction (optional, v1 only) + Memo *[]byte // memo for the transaction (optional, v1 only) + + // Signature values + V *big.Int `json:"v" gencodec:"required"` + R *big.Int `json:"r" gencodec:"required"` + S *big.Int `json:"s" gencodec:"required"` +} + +// morphTxV0RLP is the RLP encoding structure for MorphTx version 0 (legacy format) +type v0MorphTxRLP struct { + ChainID *big.Int + Nonce uint64 + GasTipCap *big.Int + GasFeeCap *big.Int + Gas uint64 + To *common.Address `rlp:"nil"` + Value *big.Int + Data []byte + AccessList AccessList + FeeTokenID uint16 + FeeLimit *big.Int + V *big.Int + R *big.Int + S *big.Int +} + +// morphTxV1RLP is the RLP encoding structure for MorphTx version 1 (with Reference/Memo) +// Note: Version is NOT included here - it's encoded as a prefix byte before the RLP data +type v1MorphTxRLP struct { + ChainID *big.Int + Nonce uint64 + GasTipCap *big.Int + GasFeeCap *big.Int + Gas uint64 + To *common.Address `rlp:"nil"` + Value *big.Int + Data []byte + AccessList AccessList + FeeTokenID uint16 + FeeLimit *big.Int + Reference []byte // Use []byte for RLP compatibility (common.Reference is [32]byte, can't decode empty) + Memo []byte // Use []byte for RLP compatibility + V *big.Int + R *big.Int + S *big.Int +} + +// copy creates a deep copy of the transaction data and initializes all fields. +func (tx *MorphTx) copy() TxData { + cpy := &MorphTx{ + Nonce: tx.Nonce, + Gas: tx.Gas, + To: copyAddressPtr(tx.To), + Data: common.CopyBytes(tx.Data), + FeeTokenID: tx.FeeTokenID, + Version: tx.Version, + Reference: copyReferencePtr(tx.Reference), + Memo: copyBytesPtr(tx.Memo), + // These are copied below. + AccessList: make(AccessList, len(tx.AccessList)), + Value: new(big.Int), + ChainID: new(big.Int), + GasTipCap: new(big.Int), + GasFeeCap: new(big.Int), + V: new(big.Int), + R: new(big.Int), + S: new(big.Int), + } + copy(cpy.AccessList, tx.AccessList) + if tx.Value != nil { + cpy.Value.Set(tx.Value) + } + if tx.ChainID != nil { + cpy.ChainID.Set(tx.ChainID) + } + if tx.GasTipCap != nil { + cpy.GasTipCap.Set(tx.GasTipCap) + } + if tx.GasFeeCap != nil { + cpy.GasFeeCap.Set(tx.GasFeeCap) + } + if tx.FeeLimit != nil { + cpy.FeeLimit = new(big.Int).Set(tx.FeeLimit) + } + if tx.V != nil { + cpy.V.Set(tx.V) + } + if tx.R != nil { + cpy.R.Set(tx.R) + } + if tx.S != nil { + cpy.S.Set(tx.S) + } + return cpy +} + +// accessors for innerTx. +func (tx *MorphTx) txType() byte { return MorphTxType } +func (tx *MorphTx) chainID() *big.Int { return tx.ChainID } +func (tx *MorphTx) accessList() AccessList { return tx.AccessList } +func (tx *MorphTx) data() []byte { return tx.Data } +func (tx *MorphTx) gas() uint64 { return tx.Gas } +func (tx *MorphTx) gasFeeCap() *big.Int { return tx.GasFeeCap } +func (tx *MorphTx) gasTipCap() *big.Int { return tx.GasTipCap } +func (tx *MorphTx) gasPrice() *big.Int { return tx.GasFeeCap } +func (tx *MorphTx) value() *big.Int { return tx.Value } +func (tx *MorphTx) nonce() uint64 { return tx.Nonce } +func (tx *MorphTx) to() *common.Address { return tx.To } + +func (tx *MorphTx) effectiveGasPrice(dst *big.Int, baseFee *big.Int) *big.Int { + if baseFee == nil { + return dst.Set(tx.GasFeeCap) + } + tip := dst.Sub(tx.GasFeeCap, baseFee) + if tip.Cmp(tx.GasTipCap) > 0 { + tip.Set(tx.GasTipCap) + } + return tip.Add(tip, baseFee) +} + +func (tx *MorphTx) rawSignatureValues() (v, r, s *big.Int) { + return tx.V, tx.R, tx.S +} + +func (tx *MorphTx) setSignatureValues(chainID, v, r, s *big.Int) { + tx.ChainID, tx.V, tx.R, tx.S = chainID, v, r, s +} + +// EncodeRLP implements rlp.Encoder so that rlp.Encode produces the same output +// as the custom encode() method. Without this, Hash() falls back to reflection-based +// struct encoding which has different field order/count than the wire format. +func (tx *MorphTx) EncodeRLP(w io.Writer) error { + buf := new(bytes.Buffer) + if err := tx.encode(buf); err != nil { + return err + } + _, err := w.Write(buf.Bytes()) + return err +} + +func (tx *MorphTx) encode(b *bytes.Buffer) error { + switch tx.Version { + case MorphTxVersion0: + // Validate FeeTokenID for v0 (must match decodeV0MorphTxRLP behavior) + if tx.FeeTokenID == 0 { + return errors.New("invalid FeeTokenID for v0 morph tx") + } + // Encode as v0 format (legacy AltFeeTx compatible) + // Format: RLP([chainID, nonce, ..., feeLimit, v, r, s]) + return rlp.Encode(b, &v0MorphTxRLP{ + ChainID: tx.ChainID, + Nonce: tx.Nonce, + GasTipCap: tx.GasTipCap, + GasFeeCap: tx.GasFeeCap, + Gas: tx.Gas, + To: tx.To, + Value: tx.Value, + Data: tx.Data, + AccessList: tx.AccessList, + FeeTokenID: tx.FeeTokenID, + FeeLimit: tx.FeeLimit, + V: tx.V, + R: tx.R, + S: tx.S, + }) + case MorphTxVersion1: + // Encode as v1 format: [version byte] + RLP([...fields..., reference, memo]) + // Version is encoded as a prefix byte (similar to txType), not inside RLP + b.WriteByte(tx.Version) + // Convert pointer types to []byte for RLP encoding + var reference []byte + if tx.Reference != nil { + reference = tx.Reference[:] + } + var memo []byte + if tx.Memo != nil { + memo = *tx.Memo + } + return rlp.Encode(b, &v1MorphTxRLP{ + ChainID: tx.ChainID, + Nonce: tx.Nonce, + GasTipCap: tx.GasTipCap, + GasFeeCap: tx.GasFeeCap, + Gas: tx.Gas, + To: tx.To, + Value: tx.Value, + Data: tx.Data, + AccessList: tx.AccessList, + FeeTokenID: tx.FeeTokenID, + FeeLimit: tx.FeeLimit, + Reference: reference, + Memo: memo, + V: tx.V, + R: tx.R, + S: tx.S, + }) + default: + return errors.New("unsupported morph tx version: " + strconv.Itoa(int(tx.Version))) + } +} + +func (tx *MorphTx) decode(input []byte) error { + if len(input) == 0 { + return errors.New("empty morph tx input") + } + + // Check first byte to determine version: + // - V0 format (legacy AltFeeTx): first byte is 0 or RLP list prefix (0xC0-0xFF) + // - V1+ format: first byte is version (0x01, 0x02, ...) followed by RLP + firstByte := input[0] + if firstByte == 0 || firstByte >= 0xC0 { + // V0 format: direct RLP decode (legacy compatible) + return decodeV0MorphTxRLP(tx, input) + } + + // V1+ format: first byte is version, rest is RLP + if firstByte != MorphTxVersion1 { + return errors.New("unsupported morph tx version: " + strconv.Itoa(int(firstByte))) + } + return decodeV1MorphTxRLP(tx, input[1:]) +} + +func decodeV1MorphTxRLP(tx *MorphTx, blob []byte) error { + var v1 v1MorphTxRLP + if err := rlp.DecodeBytes(blob, &v1); err != nil { + return err + } + + tx.ChainID = v1.ChainID + tx.Nonce = v1.Nonce + tx.GasTipCap = v1.GasTipCap + tx.GasFeeCap = v1.GasFeeCap + tx.Gas = v1.Gas + tx.To = v1.To + tx.Value = v1.Value + tx.Data = v1.Data + tx.AccessList = v1.AccessList + tx.Version = MorphTxVersion1 + tx.FeeTokenID = v1.FeeTokenID + tx.FeeLimit = v1.FeeLimit + // Convert []byte to *common.Reference + if len(v1.Reference) != 0 && len(v1.Reference) != common.ReferenceLength { + return errors.New("invalid reference length: expected 0 or " + strconv.Itoa(common.ReferenceLength) + ", got " + strconv.Itoa(len(v1.Reference))) + } + if len(v1.Reference) == common.ReferenceLength { + ref := common.BytesToReference(v1.Reference) + tx.Reference = &ref + } + // Convert []byte to *[]byte and validate memo length + if len(v1.Memo) > common.MaxMemoLength { + return errors.New("memo exceeds maximum length of " + strconv.Itoa(common.MaxMemoLength) + " bytes, got " + strconv.Itoa(len(v1.Memo))) + } + if len(v1.Memo) > 0 { + tx.Memo = &v1.Memo + } + tx.V = v1.V + tx.R = v1.R + tx.S = v1.S + + return nil +} + +func decodeV0MorphTxRLP(tx *MorphTx, blob []byte) error { + var v0 v0MorphTxRLP + if err := rlp.DecodeBytes(blob, &v0); err != nil { + return err + } + + if v0.FeeTokenID == 0 { + return errors.New("invalid fee token id, expected non-zero") + } + + tx.ChainID = v0.ChainID + tx.Nonce = v0.Nonce + tx.GasTipCap = v0.GasTipCap + tx.GasFeeCap = v0.GasFeeCap + tx.Gas = v0.Gas + tx.To = v0.To + tx.Value = v0.Value + tx.Data = v0.Data + tx.AccessList = v0.AccessList + tx.FeeTokenID = v0.FeeTokenID + tx.FeeLimit = v0.FeeLimit + tx.V = v0.V + tx.R = v0.R + tx.S = v0.S + + return nil +} + +func (tx *MorphTx) sigHash(chainID *big.Int) common.Hash { + if tx.Version == MorphTxVersion0 { + return tx.v0SigHash(chainID) + } + return tx.v1SigHash(chainID) +} + +func (tx *MorphTx) v1SigHash(chainID *big.Int) common.Hash { + return prefixedRlpHash( + MorphTxType, + []any{ + chainID, + tx.Nonce, + tx.GasTipCap, + tx.GasFeeCap, + tx.Gas, + tx.To, + tx.Value, + tx.Data, + tx.AccessList, + tx.FeeTokenID, + tx.FeeLimit, + tx.Version, + tx.Reference, + tx.Memo, + }) +} + +func (tx *MorphTx) v0SigHash(chainID *big.Int) common.Hash { + return prefixedRlpHash( + MorphTxType, + []any{ + chainID, + tx.Nonce, + tx.GasTipCap, + tx.GasFeeCap, + tx.Gas, + tx.To, + tx.Value, + tx.Data, + tx.AccessList, + tx.FeeTokenID, + tx.FeeLimit, + }) +} diff --git a/core/types/morph_tx_compat_test.go b/core/types/morph_tx_compat_test.go new file mode 100644 index 000000000..464fcc96b --- /dev/null +++ b/core/types/morph_tx_compat_test.go @@ -0,0 +1,593 @@ +package types + +import ( + "bytes" + "encoding/hex" + "math/big" + "testing" + + "github.com/morph-l2/go-ethereum/common" + "github.com/morph-l2/go-ethereum/crypto" + "github.com/morph-l2/go-ethereum/rlp" +) + +// TestMorphTxV0BackwardCompatibility tests that old AltFeeTx encoded data +// can be correctly decoded by the new MorphTx decoder. +// These hex values were generated from the original AltFeeTx implementation. +func TestMorphTxV0BackwardCompatibility(t *testing.T) { + // Expected values from the original encoding + expectedTo := common.HexToAddress("0x1234567890123456789012345678901234567890") + expectedChainID := big.NewInt(2818) + expectedNonce := uint64(1) + expectedGasTipCap := big.NewInt(1000000000) + expectedGasFeeCap := big.NewInt(2000000000) + expectedGas := uint64(21000) + expectedValue := big.NewInt(1000000000000000000) // 1 ETH + expectedR, _ := new(big.Int).SetString("abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", 16) + expectedS, _ := new(big.Int).SetString("1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", 16) + + testCases := []struct { + name string + fullHex string // Full hex including 0x7F prefix + feeTokenID uint16 + feeLimit *big.Int + }{ + { + // Case 1: FeeLimit has value (0.5 ETH = 500000000000000000) + name: "V0 with FeeLimit value", + fullHex: "7ff87e820b0201843b9aca008477359400825208941234567890123456789012345678901234567890880de0b6b3a764000080c0018806f05b59d3b2000001a0abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890a01234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", + feeTokenID: 1, + feeLimit: big.NewInt(500000000000000000), + }, + { + // Case 2: FeeLimit is nil (encoded as 0x80) + name: "V0 with nil FeeLimit", + fullHex: "7ff876820b0201843b9aca008477359400825208941234567890123456789012345678901234567890880de0b6b3a764000080c0018001a0abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890a01234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", + feeTokenID: 1, + feeLimit: nil, + }, + { + // Case 3: FeeLimit is 0 (also encoded as 0x80) + name: "V0 with zero FeeLimit", + fullHex: "7ff876820b0201843b9aca008477359400825208941234567890123456789012345678901234567890880de0b6b3a764000080c0018001a0abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890a01234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", + feeTokenID: 1, + feeLimit: nil, // 0 is encoded as empty, decoded as nil + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + data, err := hex.DecodeString(tc.fullHex) + if err != nil { + t.Fatalf("failed to decode hex: %v", err) + } + + // Verify first byte is MorphTxType (0x7F) + if data[0] != MorphTxType { + t.Fatalf("expected first byte 0x7F, got 0x%x", data[0]) + } + + // Skip txType byte, decode the rest + innerData := data[1:] + t.Logf("First inner byte: 0x%x (should be RLP list prefix >= 0xC0)", innerData[0]) + + // Verify it's RLP list prefix (V0 format) + if innerData[0] < 0xC0 { + t.Errorf("V0 data should start with RLP list prefix, got 0x%x", innerData[0]) + } + + // Decode using MorphTx.decode + var decoded MorphTx + if err := decoded.decode(innerData); err != nil { + t.Fatalf("failed to decode MorphTx: %v", err) + } + + // Verify version is 0 (V0 format) + if decoded.Version != MorphTxVersion0 { + t.Errorf("expected Version 0, got %d", decoded.Version) + } + + // Verify FeeTokenID + if decoded.FeeTokenID != tc.feeTokenID { + t.Errorf("expected FeeTokenID %d, got %d", tc.feeTokenID, decoded.FeeTokenID) + } + + // Verify FeeLimit + if tc.feeLimit == nil { + if decoded.FeeLimit != nil && decoded.FeeLimit.Sign() != 0 { + t.Errorf("expected nil/zero FeeLimit, got %v", decoded.FeeLimit) + } + } else { + if decoded.FeeLimit == nil || decoded.FeeLimit.Cmp(tc.feeLimit) != 0 { + t.Errorf("expected FeeLimit %v, got %v", tc.feeLimit, decoded.FeeLimit) + } + } + + // Verify other common fields + if decoded.ChainID.Cmp(expectedChainID) != 0 { + t.Errorf("ChainID mismatch: expected %v, got %v", expectedChainID, decoded.ChainID) + } + if decoded.Nonce != expectedNonce { + t.Errorf("Nonce mismatch: expected %d, got %d", expectedNonce, decoded.Nonce) + } + if decoded.GasTipCap.Cmp(expectedGasTipCap) != 0 { + t.Errorf("GasTipCap mismatch: expected %v, got %v", expectedGasTipCap, decoded.GasTipCap) + } + if decoded.GasFeeCap.Cmp(expectedGasFeeCap) != 0 { + t.Errorf("GasFeeCap mismatch: expected %v, got %v", expectedGasFeeCap, decoded.GasFeeCap) + } + if decoded.Gas != expectedGas { + t.Errorf("Gas mismatch: expected %d, got %d", expectedGas, decoded.Gas) + } + if decoded.To == nil || *decoded.To != expectedTo { + t.Errorf("To mismatch: expected %v, got %v", expectedTo, decoded.To) + } + if decoded.Value.Cmp(expectedValue) != 0 { + t.Errorf("Value mismatch: expected %v, got %v", expectedValue, decoded.Value) + } + if decoded.R.Cmp(expectedR) != 0 { + t.Errorf("R mismatch: expected %v, got %v", expectedR, decoded.R) + } + if decoded.S.Cmp(expectedS) != 0 { + t.Errorf("S mismatch: expected %v, got %v", expectedS, decoded.S) + } + + t.Logf("Successfully decoded V0 MorphTx: ChainID=%v, Nonce=%d, FeeTokenID=%d, FeeLimit=%v, Version=%d", + decoded.ChainID, decoded.Nonce, decoded.FeeTokenID, decoded.FeeLimit, decoded.Version) + }) + } +} + +// encodeMorphTx encodes a MorphTx using its encode method +func encodeMorphTx(tx *MorphTx) ([]byte, error) { + buf := new(bytes.Buffer) + buf.WriteByte(MorphTxType) // Write txType prefix + if err := tx.encode(buf); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +// TestMorphTxV1Encoding tests the new V1 encoding format +// where version is a prefix byte before RLP data. +func TestMorphTxV1Encoding(t *testing.T) { + reference := common.HexToReference("0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef") + memo := []byte("test memo") + to := common.HexToAddress("0x1234567890123456789012345678901234567890") + + tx := &MorphTx{ + ChainID: big.NewInt(2818), + Nonce: 1, + GasTipCap: big.NewInt(1000000000), + GasFeeCap: big.NewInt(2000000000), + Gas: 21000, + To: &to, + Value: big.NewInt(0), + Data: []byte{}, + AccessList: AccessList{}, + FeeTokenID: 0, // ETH + FeeLimit: nil, + Version: MorphTxVersion1, + Reference: &reference, + Memo: &memo, + V: big.NewInt(0), + R: big.NewInt(0), + S: big.NewInt(0), + } + + // Encode + encoded, err := encodeMorphTx(tx) + if err != nil { + t.Fatalf("failed to encode: %v", err) + } + + t.Logf("V1 encoded hex: %s", hex.EncodeToString(encoded)) + t.Logf("First byte (type): 0x%x", encoded[0]) + t.Logf("Second byte (version): 0x%x", encoded[1]) + + // Verify first byte is MorphTxType + if encoded[0] != MorphTxType { + t.Errorf("expected first byte 0x%x, got 0x%x", MorphTxType, encoded[0]) + } + + // Verify second byte is version + if encoded[1] != MorphTxVersion1 { + t.Errorf("expected second byte 0x%x (version 1), got 0x%x", MorphTxVersion1, encoded[1]) + } + + // Decode back + var decoded MorphTx + if err := decoded.decode(encoded[1:]); err != nil { // Skip txType byte + t.Fatalf("failed to decode: %v", err) + } + + // Verify fields + if decoded.Version != MorphTxVersion1 { + t.Errorf("expected Version 1, got %d", decoded.Version) + } + if decoded.Reference == nil || *decoded.Reference != reference { + t.Errorf("Reference mismatch") + } + if decoded.Memo == nil || string(*decoded.Memo) != string(memo) { + t.Errorf("Memo mismatch") + } + + t.Logf("Successfully encoded and decoded V1 MorphTx") +} + +// TestMorphTxV0V1RoundTrip tests encoding/decoding round trip for both versions +func TestMorphTxV0V1RoundTrip(t *testing.T) { + to := common.HexToAddress("0x1234567890123456789012345678901234567890") + reference := common.HexToReference("0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890") + memo := []byte("hello") + + testCases := []struct { + name string + tx *MorphTx + }{ + { + name: "V0 with FeeTokenID", + tx: &MorphTx{ + ChainID: big.NewInt(1), + Nonce: 1, + GasTipCap: big.NewInt(1000000000), + GasFeeCap: big.NewInt(2000000000), + Gas: 21000, + To: &to, + Value: big.NewInt(1000000000000000000), + Data: []byte{}, + AccessList: AccessList{}, + FeeTokenID: 1, // Non-zero required for V0 + FeeLimit: big.NewInt(100000000000000000), + Version: MorphTxVersion0, + V: big.NewInt(1), + R: big.NewInt(123456), + S: big.NewInt(654321), + }, + }, + { + name: "V1 with Reference and Memo", + tx: &MorphTx{ + ChainID: big.NewInt(1), + Nonce: 2, + GasTipCap: big.NewInt(1000000000), + GasFeeCap: big.NewInt(2000000000), + Gas: 21000, + To: &to, + Value: big.NewInt(0), + Data: []byte{0x01, 0x02, 0x03}, + AccessList: AccessList{}, + FeeTokenID: 0, + FeeLimit: nil, + Version: MorphTxVersion1, + Reference: &reference, + Memo: &memo, + V: big.NewInt(0), + R: big.NewInt(111), + S: big.NewInt(222), + }, + }, + { + name: "V1 with FeeTokenID and Reference", + tx: &MorphTx{ + ChainID: big.NewInt(1), + Nonce: 3, + GasTipCap: big.NewInt(1000000000), + GasFeeCap: big.NewInt(2000000000), + Gas: 50000, + To: &to, + Value: big.NewInt(500000000000000000), + Data: []byte{}, + AccessList: AccessList{}, + FeeTokenID: 2, + FeeLimit: big.NewInt(200000000000000000), + Version: MorphTxVersion1, + Reference: &reference, + Memo: nil, + V: big.NewInt(1), + R: big.NewInt(333), + S: big.NewInt(444), + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Encode + encoded, err := encodeMorphTx(tc.tx) + if err != nil { + t.Fatalf("failed to encode: %v", err) + } + + t.Logf("Encoded hex: %s", hex.EncodeToString(encoded)) + t.Logf("Length: %d bytes", len(encoded)) + + // Decode + var decoded MorphTx + if err := decoded.decode(encoded[1:]); err != nil { // Skip txType byte + t.Fatalf("failed to decode: %v", err) + } + + // Verify key fields + if decoded.Version != tc.tx.Version { + t.Errorf("Version mismatch: expected %d, got %d", tc.tx.Version, decoded.Version) + } + if decoded.FeeTokenID != tc.tx.FeeTokenID { + t.Errorf("FeeTokenID mismatch: expected %d, got %d", tc.tx.FeeTokenID, decoded.FeeTokenID) + } + if decoded.Nonce != tc.tx.Nonce { + t.Errorf("Nonce mismatch: expected %d, got %d", tc.tx.Nonce, decoded.Nonce) + } + if decoded.Gas != tc.tx.Gas { + t.Errorf("Gas mismatch: expected %d, got %d", tc.tx.Gas, decoded.Gas) + } + + t.Logf("Round-trip successful for %s", tc.name) + }) + } +} + +// TestMorphTxVersionDetection tests the version detection logic in decode +func TestMorphTxVersionDetection(t *testing.T) { + // Create a V0 transaction (legacy format) + to := common.HexToAddress("0x1234567890123456789012345678901234567890") + v0Tx := &MorphTx{ + ChainID: big.NewInt(1), + Nonce: 1, + GasTipCap: big.NewInt(1000000000), + GasFeeCap: big.NewInt(2000000000), + Gas: 21000, + To: &to, + Value: big.NewInt(0), + FeeTokenID: 1, + FeeLimit: big.NewInt(100), + Version: MorphTxVersion0, + V: big.NewInt(0), + R: big.NewInt(0), + S: big.NewInt(0), + } + + encoded, err := encodeMorphTx(v0Tx) + if err != nil { + t.Fatalf("failed to encode V0: %v", err) + } + innerData := encoded[1:] // Skip txType + + // V0 should start with RLP list prefix (0xC0-0xFF) + if innerData[0] < 0xC0 { + t.Errorf("V0 encoded data should start with RLP list prefix, got 0x%x", innerData[0]) + } + t.Logf("V0 first inner byte: 0x%x (RLP list prefix)", innerData[0]) + + // Create a V1 transaction + reference := common.HexToReference("0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef") + v1Tx := &MorphTx{ + ChainID: big.NewInt(1), + Nonce: 1, + GasTipCap: big.NewInt(1000000000), + GasFeeCap: big.NewInt(2000000000), + Gas: 21000, + To: &to, + Value: big.NewInt(0), + FeeTokenID: 0, + Version: MorphTxVersion1, + Reference: &reference, + V: big.NewInt(0), + R: big.NewInt(0), + S: big.NewInt(0), + } + + encoded, err = encodeMorphTx(v1Tx) + if err != nil { + t.Fatalf("failed to encode V1: %v", err) + } + innerData = encoded[1:] // Skip txType + + // V1 should start with version byte (0x01) + if innerData[0] != MorphTxVersion1 { + t.Errorf("V1 encoded data should start with version byte 0x01, got 0x%x", innerData[0]) + } + t.Logf("V1 first inner byte: 0x%x (version prefix)", innerData[0]) + + // Second byte should be RLP list prefix + if innerData[1] < 0xC0 { + t.Errorf("V1 second byte should be RLP list prefix, got 0x%x", innerData[1]) + } + t.Logf("V1 second inner byte: 0x%x (RLP list prefix)", innerData[1]) +} + +// TestMorphTxEncodeRLPConsistency verifies that rlp.Encode(morphTx) produces +// the same output as the custom encode() method. This ensures Hash() (which +// uses rlp.Encode internally) is consistent with the wire format. +func TestMorphTxEncodeRLPConsistency(t *testing.T) { + to := common.HexToAddress("0x1234567890123456789012345678901234567890") + reference := common.HexToReference("0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890") + memo := []byte("hello") + + testCases := []struct { + name string + tx *MorphTx + }{ + { + name: "V0", + tx: &MorphTx{ + ChainID: big.NewInt(1), + Nonce: 1, + GasTipCap: big.NewInt(1000000000), + GasFeeCap: big.NewInt(2000000000), + Gas: 21000, + To: &to, + Value: big.NewInt(1000000000000000000), + Data: []byte{}, + AccessList: AccessList{}, + FeeTokenID: 1, + FeeLimit: big.NewInt(100000000000000000), + Version: MorphTxVersion0, + V: big.NewInt(1), + R: big.NewInt(123456), + S: big.NewInt(654321), + }, + }, + { + name: "V1 with Reference and Memo", + tx: &MorphTx{ + ChainID: big.NewInt(1), + Nonce: 2, + GasTipCap: big.NewInt(1000000000), + GasFeeCap: big.NewInt(2000000000), + Gas: 21000, + To: &to, + Value: big.NewInt(0), + Data: []byte{0x01, 0x02, 0x03}, + AccessList: AccessList{}, + FeeTokenID: 0, + FeeLimit: nil, + Version: MorphTxVersion1, + Reference: &reference, + Memo: &memo, + V: big.NewInt(0), + R: big.NewInt(111), + S: big.NewInt(222), + }, + }, + { + name: "V1 minimal", + tx: &MorphTx{ + ChainID: big.NewInt(1), + Nonce: 3, + GasTipCap: big.NewInt(1000000000), + GasFeeCap: big.NewInt(2000000000), + Gas: 50000, + To: &to, + Value: big.NewInt(0), + Data: []byte{}, + AccessList: AccessList{}, + FeeTokenID: 0, + FeeLimit: nil, + Version: MorphTxVersion1, + Reference: nil, + Memo: nil, + V: big.NewInt(0), + R: big.NewInt(0), + S: big.NewInt(0), + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Path 1: rlp.Encode (used by Hash via prefixedRlpHash) + var rlpBuf bytes.Buffer + if err := rlp.Encode(&rlpBuf, tc.tx); err != nil { + t.Fatalf("rlp.Encode failed: %v", err) + } + + // Path 2: custom encode() (used by wire format via encodeTyped) + var encodeBuf bytes.Buffer + if err := tc.tx.encode(&encodeBuf); err != nil { + t.Fatalf("encode() failed: %v", err) + } + + if !bytes.Equal(rlpBuf.Bytes(), encodeBuf.Bytes()) { + t.Errorf("rlp.Encode and encode() produce different output:\n rlp.Encode = %s\n encode() = %s", + hex.EncodeToString(rlpBuf.Bytes()), hex.EncodeToString(encodeBuf.Bytes())) + } + }) + } +} + +// TestMorphTxHashMatchesWireFormat verifies that tx.Hash() equals +// keccak256(wire_bytes) for both V0 and V1 MorphTx transactions. +func TestMorphTxHashMatchesWireFormat(t *testing.T) { + to := common.HexToAddress("0x1234567890123456789012345678901234567890") + reference := common.HexToReference("0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890") + memo := []byte("hello") + + testCases := []struct { + name string + tx *MorphTx + }{ + { + name: "V0", + tx: &MorphTx{ + ChainID: big.NewInt(1), + Nonce: 1, + GasTipCap: big.NewInt(1000000000), + GasFeeCap: big.NewInt(2000000000), + Gas: 21000, + To: &to, + Value: big.NewInt(1000000000000000000), + Data: []byte{}, + AccessList: AccessList{}, + FeeTokenID: 1, + FeeLimit: big.NewInt(100000000000000000), + Version: MorphTxVersion0, + V: big.NewInt(1), + R: big.NewInt(123456), + S: big.NewInt(654321), + }, + }, + { + name: "V1 with Reference and Memo", + tx: &MorphTx{ + ChainID: big.NewInt(1), + Nonce: 2, + GasTipCap: big.NewInt(1000000000), + GasFeeCap: big.NewInt(2000000000), + Gas: 21000, + To: &to, + Value: big.NewInt(0), + Data: []byte{0x01, 0x02, 0x03}, + AccessList: AccessList{}, + FeeTokenID: 0, + FeeLimit: nil, + Version: MorphTxVersion1, + Reference: &reference, + Memo: &memo, + V: big.NewInt(0), + R: big.NewInt(111), + S: big.NewInt(222), + }, + }, + { + name: "V1 minimal", + tx: &MorphTx{ + ChainID: big.NewInt(1), + Nonce: 3, + GasTipCap: big.NewInt(1000000000), + GasFeeCap: big.NewInt(2000000000), + Gas: 50000, + To: &to, + Value: big.NewInt(0), + Data: []byte{}, + AccessList: AccessList{}, + FeeTokenID: 0, + FeeLimit: nil, + Version: MorphTxVersion1, + Reference: nil, + Memo: nil, + V: big.NewInt(0), + R: big.NewInt(0), + S: big.NewInt(0), + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + tx := NewTx(tc.tx) + + wireBytes, err := tx.MarshalBinary() + if err != nil { + t.Fatalf("MarshalBinary failed: %v", err) + } + + expectedHash := crypto.Keccak256Hash(wireBytes) + + if tx.Hash() != expectedHash { + t.Errorf("Hash mismatch:\n tx.Hash() = %s\n keccak256(wire) = %s\n wireBytes = %s", + tx.Hash().Hex(), expectedHash.Hex(), hex.EncodeToString(wireBytes)) + } + }) + } +} diff --git a/core/types/receipt.go b/core/types/receipt.go index 96bb17f98..303459420 100644 --- a/core/types/receipt.go +++ b/core/types/receipt.go @@ -78,11 +78,15 @@ type Receipt struct { // Morph rollup L1Fee *big.Int `json:"l1Fee,omitempty"` - // Alt Fee - FeeTokenID *uint16 `json:"feeTokenID,omitempty"` - FeeRate *big.Int `json:"feeRate,omitempty"` - TokenScale *big.Int `json:"tokenScale,omitempty"` - FeeLimit *big.Int `json:"feeLimit,omitempty"` + + // MorphTx fields + FeeTokenID *uint16 `json:"feeTokenID,omitempty"` + FeeRate *big.Int `json:"feeRate,omitempty"` + TokenScale *big.Int `json:"tokenScale,omitempty"` + FeeLimit *big.Int `json:"feeLimit,omitempty"` + Version uint8 `json:"version,omitempty"` + Reference *common.Reference `json:"reference,omitempty"` + Memo *[]byte `json:"memo,omitempty"` } type receiptMarshaling struct { @@ -101,6 +105,9 @@ type receiptMarshaling struct { FeeRate *hexutil.Big TokenScale *hexutil.Big FeeLimit *hexutil.Big + Version hexutil.Uint64 + Reference *common.Reference + Memo *hexutil.Bytes } // receiptRLP is the consensus encoding of a receipt. @@ -112,6 +119,10 @@ type receiptRLP struct { } // storedReceiptRLP is the storage encoding of a receipt. +// Note: Version, Reference, and Memo are marked as rlp:"optional" for backward compatibility. +// When these fields are zero-valued (V0 receipts), they are omitted from the RLP encoding, +// producing an 8-element list identical to v7StoredReceiptRLP format. This allows older nodes +// (without V1 logic) to decode receipts stored by newer nodes before the Jade fork activates. type storedReceiptRLP struct { PostStateOrStatus []byte CumulativeGasUsed uint64 @@ -121,6 +132,26 @@ type storedReceiptRLP struct { FeeRate *big.Int TokenScale *big.Int FeeLimit *big.Int + Version uint8 `rlp:"optional"` + Reference []byte `rlp:"optional"` // Note: use []byte for RLP compatibility (common.Reference is [32]byte, can't decode empty) + Memo []byte `rlp:"optional"` // Note: use []byte for RLP compatibility, convert to *[]byte when needed +} + +// v8StoredReceiptRLP is the storage encoding of a receipt used in database version 8. +// This version was introduced when MorphTx feature was added. +// It includes L1Fee and all MorphTx fields (FeeTokenID, FeeRate, TokenScale, FeeLimit, Version, Reference, Memo). +type v8StoredReceiptRLP struct { + PostStateOrStatus []byte + CumulativeGasUsed uint64 + Logs []*LogForStorage + L1Fee *big.Int + FeeTokenID *uint16 + FeeRate *big.Int + TokenScale *big.Int + FeeLimit *big.Int + Version uint8 + Reference []byte // Note: use []byte for RLP compatibility (common.Reference is [32]byte, can't decode empty) + Memo []byte // Note: use []byte for RLP compatibility } // v7StoredReceiptRLP is the storage encoding of a receipt used in database version 7. @@ -277,7 +308,7 @@ func (r *Receipt) decodeTyped(b []byte) error { return errShortTypedReceipt } switch b[0] { - case DynamicFeeTxType, AccessListTxType, BlobTxType, L1MessageTxType, SetCodeTxType, AltFeeTxType: + case DynamicFeeTxType, AccessListTxType, BlobTxType, L1MessageTxType, SetCodeTxType, MorphTxType: var data receiptRLP err := rlp.DecodeBytes(b[1:], &data) if err != nil { @@ -337,6 +368,15 @@ type ReceiptForStorage Receipt // EncodeRLP implements rlp.Encoder, and flattens all content fields of a receipt // into an RLP stream. func (r *ReceiptForStorage) EncodeRLP(w io.Writer) error { + // Convert pointer types to []byte for RLP encoding + var memo []byte + if r.Memo != nil { + memo = *r.Memo + } + var reference []byte + if r.Reference != nil { + reference = r.Reference[:] + } enc := &storedReceiptRLP{ PostStateOrStatus: (*Receipt)(r).statusEncoding(), CumulativeGasUsed: r.CumulativeGasUsed, @@ -346,6 +386,9 @@ func (r *ReceiptForStorage) EncodeRLP(w io.Writer) error { FeeRate: r.FeeRate, TokenScale: r.TokenScale, FeeLimit: r.FeeLimit, + Version: r.Version, + Reference: reference, + Memo: memo, } for i, log := range r.Logs { enc.Logs[i] = (*LogForStorage)(log) @@ -367,6 +410,9 @@ func (r *ReceiptForStorage) DecodeRLP(s *rlp.Stream) error { if err := decodeStoredReceiptRLP(r, blob); err == nil { return nil } + if err := decodeV8StoredReceiptRLP(r, blob); err == nil { + return nil + } if err := decodeV7StoredReceiptRLP(r, blob); err == nil { return nil } @@ -401,6 +447,49 @@ func decodeStoredReceiptRLP(r *ReceiptForStorage, blob []byte) error { r.FeeRate = stored.FeeRate r.TokenScale = stored.TokenScale r.FeeLimit = stored.FeeLimit + r.Version = stored.Version + // Convert []byte to *common.Reference + if len(stored.Reference) == common.ReferenceLength { + ref := common.BytesToReference(stored.Reference) + r.Reference = &ref + } + // Convert []byte to *[]byte + if len(stored.Memo) > 0 { + r.Memo = &stored.Memo + } + + return nil +} + +func decodeV8StoredReceiptRLP(r *ReceiptForStorage, blob []byte) error { + var stored v8StoredReceiptRLP + if err := rlp.DecodeBytes(blob, &stored); err != nil { + return err + } + if err := (*Receipt)(r).setStatus(stored.PostStateOrStatus); err != nil { + return err + } + r.CumulativeGasUsed = stored.CumulativeGasUsed + r.Logs = make([]*Log, len(stored.Logs)) + for i, log := range stored.Logs { + r.Logs[i] = (*Log)(log) + } + r.Bloom = CreateBloom(Receipts{(*Receipt)(r)}) + r.L1Fee = stored.L1Fee + r.FeeTokenID = stored.FeeTokenID + r.FeeRate = stored.FeeRate + r.TokenScale = stored.TokenScale + r.FeeLimit = stored.FeeLimit + r.Version = stored.Version + // Convert []byte to *common.Reference + if len(stored.Reference) == common.ReferenceLength { + ref := common.BytesToReference(stored.Reference) + r.Reference = &ref + } + // Convert []byte to *[]byte + if len(stored.Memo) > 0 { + r.Memo = &stored.Memo + } return nil } @@ -531,8 +620,8 @@ func (rs Receipts) EncodeIndex(i int, w *bytes.Buffer) { case L1MessageTxType: w.WriteByte(L1MessageTxType) rlp.Encode(w, data) - case AltFeeTxType: - w.WriteByte(AltFeeTxType) + case MorphTxType: + w.WriteByte(MorphTxType) rlp.Encode(w, data) case SetCodeTxType: w.WriteByte(SetCodeTxType) diff --git a/core/types/receipt_test.go b/core/types/receipt_test.go index 0939f429d..1ddaeb1b5 100644 --- a/core/types/receipt_test.go +++ b/core/types/receipt_test.go @@ -80,7 +80,7 @@ var ( }, Type: DynamicFeeTxType, } - altFeeReceipt = &Receipt{ + morphTxReceipt = &Receipt{ Status: ReceiptStatusFailed, CumulativeGasUsed: 1, Logs: []*Log{ @@ -95,7 +95,108 @@ var ( Data: []byte{0x01, 0x00, 0xff}, }, }, - Type: AltFeeTxType, + Type: MorphTxType, + } + + // MorphTx receipt with Version 1 and all new fields + testReference = common.HexToReference("0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef") + testMemo = []byte("test memo data") + testFeeTokenID = uint16(1) + morphTxV1Receipt = &Receipt{ + Status: ReceiptStatusSuccessful, + CumulativeGasUsed: 100, + Logs: []*Log{ + { + Address: common.BytesToAddress([]byte{0x11}), + Topics: []common.Hash{common.HexToHash("dead"), common.HexToHash("beef")}, + Data: []byte{0x01, 0x00, 0xff}, + }, + }, + Type: MorphTxType, + Version: MorphTxVersion1, + Reference: &testReference, + Memo: &testMemo, + FeeTokenID: &testFeeTokenID, + FeeRate: big.NewInt(1000), + TokenScale: big.NewInt(18), + FeeLimit: big.NewInt(1000000), + L1Fee: big.NewInt(500), + } + + // MorphTx receipt with Version 0 (legacy format) + // Note: V0 format doesn't use Reference/Memo, so we set them to nil equivalent zero values for RLP compatibility + zeroReference = common.Reference{} + nilMemo = []byte{} + morphTxV0Receipt = &Receipt{ + Status: ReceiptStatusSuccessful, + CumulativeGasUsed: 200, + Logs: []*Log{ + { + Address: common.BytesToAddress([]byte{0x22}), + Topics: []common.Hash{common.HexToHash("cafe"), common.HexToHash("babe")}, + Data: []byte{0x02, 0x00, 0xfe}, + }, + }, + Type: MorphTxType, + Version: MorphTxVersion0, + Reference: &zeroReference, // V0 has no Reference (use zero value for RLP) + Memo: &nilMemo, // V0 has no Memo (use empty for RLP) + FeeTokenID: &testFeeTokenID, + FeeRate: big.NewInt(2000), + TokenScale: big.NewInt(18), + FeeLimit: big.NewInt(2000000), + L1Fee: big.NewInt(600), + } + + // MorphTx receipt with only Reference (no Memo) + zeroFeeTokenID = uint16(0) + morphTxRefOnlyReceipt = &Receipt{ + Status: ReceiptStatusSuccessful, + CumulativeGasUsed: 300, + Logs: []*Log{}, + Type: MorphTxType, + Version: MorphTxVersion1, + Reference: &testReference, + Memo: &nilMemo, // Use empty memo for RLP compatibility + FeeTokenID: &zeroFeeTokenID, + FeeRate: big.NewInt(0), + TokenScale: big.NewInt(0), + FeeLimit: big.NewInt(0), + L1Fee: big.NewInt(0), + } + + // MorphTx receipt with only Memo (no Reference) + testMemoOnly = []byte("memo only test") + morphTxMemoOnlyReceipt = &Receipt{ + Status: ReceiptStatusSuccessful, + CumulativeGasUsed: 400, + Logs: []*Log{}, + Type: MorphTxType, + Version: MorphTxVersion1, + Reference: &zeroReference, // Use zero reference for RLP compatibility + Memo: &testMemoOnly, + FeeTokenID: &zeroFeeTokenID, + FeeRate: big.NewInt(0), + TokenScale: big.NewInt(0), + FeeLimit: big.NewInt(0), + L1Fee: big.NewInt(0), + } + + // MorphTx receipt with empty Memo + emptyMemo = []byte{} + morphTxEmptyMemoReceipt = &Receipt{ + Status: ReceiptStatusSuccessful, + CumulativeGasUsed: 500, + Logs: []*Log{}, + Type: MorphTxType, + Version: MorphTxVersion1, + Reference: &testReference, + Memo: &emptyMemo, + FeeTokenID: &zeroFeeTokenID, + FeeRate: big.NewInt(0), + TokenScale: big.NewInt(0), + FeeLimit: big.NewInt(0), + L1Fee: big.NewInt(0), } ) @@ -117,6 +218,10 @@ func TestLegacyReceiptDecoding(t *testing.T) { "StoredReceiptRLP", encodeAsStoredReceiptRLP, }, + { + "V8StoredReceiptRLP", + encodeAsV8StoredReceiptRLP, + }, { "V7StoredReceiptRLP", encodeAsV7StoredReceiptRLP, @@ -140,6 +245,9 @@ func TestLegacyReceiptDecoding(t *testing.T) { } tx := NewTransaction(1, common.HexToAddress("0x1"), big.NewInt(1), 1, big.NewInt(1), nil) + legacyZeroRef := common.Reference{} + legacyNilMemo := []byte{} + legacyFeeTokenID := uint16(0) receipt := &Receipt{ Status: ReceiptStatusFailed, CumulativeGasUsed: 1, @@ -158,6 +266,15 @@ func TestLegacyReceiptDecoding(t *testing.T) { TxHash: tx.Hash(), ContractAddress: common.BytesToAddress([]byte{0x01, 0x11, 0x11}), GasUsed: 111111, + // Set new fields to zero/empty values for RLP compatibility + Version: 0, + Reference: &legacyZeroRef, + Memo: &legacyNilMemo, + FeeTokenID: &legacyFeeTokenID, + FeeRate: big.NewInt(0), + TokenScale: big.NewInt(0), + FeeLimit: big.NewInt(0), + L1Fee: big.NewInt(0), } receipt.Bloom = CreateBloom(Receipts{receipt}) @@ -205,6 +322,49 @@ func encodeAsStoredReceiptRLP(want *Receipt) ([]byte, error) { CumulativeGasUsed: want.CumulativeGasUsed, Logs: make([]*LogForStorage, len(want.Logs)), L1Fee: want.L1Fee, + FeeTokenID: want.FeeTokenID, + FeeRate: want.FeeRate, + TokenScale: want.TokenScale, + FeeLimit: want.FeeLimit, + Version: want.Version, + Reference: derefReference(want.Reference), + Memo: derefMemo(want.Memo), + } + for i, log := range want.Logs { + stored.Logs[i] = (*LogForStorage)(log) + } + return rlp.EncodeToBytes(stored) +} + +// derefMemo converts *[]byte to []byte for test encoding +func derefMemo(m *[]byte) []byte { + if m == nil { + return nil + } + return *m +} + +// derefReference converts *common.Reference to []byte for test encoding +func derefReference(r *common.Reference) []byte { + if r == nil { + return nil + } + return r[:] +} + +func encodeAsV8StoredReceiptRLP(want *Receipt) ([]byte, error) { + stored := &v8StoredReceiptRLP{ + PostStateOrStatus: want.statusEncoding(), + CumulativeGasUsed: want.CumulativeGasUsed, + Logs: make([]*LogForStorage, len(want.Logs)), + L1Fee: want.L1Fee, + FeeTokenID: want.FeeTokenID, + FeeRate: want.FeeRate, + TokenScale: want.TokenScale, + FeeLimit: want.FeeLimit, + Version: want.Version, + Reference: derefReference(want.Reference), + Memo: derefMemo(want.Memo), } for i, log := range want.Logs { stored.Logs[i] = (*LogForStorage)(log) @@ -501,22 +661,22 @@ func TestReceiptMarshalBinary(t *testing.T) { t.Errorf("encoded RLP mismatch, got %x want %x", have, eip1559Want) } - // alt fee Receipt + // MorphTx Receipt buf.Reset() - altFeeReceipt.Bloom = CreateBloom(Receipts{altFeeReceipt}) - have, err = altFeeReceipt.MarshalBinary() + morphTxReceipt.Bloom = CreateBloom(Receipts{morphTxReceipt}) + have, err = morphTxReceipt.MarshalBinary() if err != nil { t.Fatalf("marshal binary error: %v", err) } - altFeeReceipts := Receipts{altFeeReceipt} - altFeeReceipts.EncodeIndex(0, buf) + morphTxReceipts := Receipts{morphTxReceipt} + morphTxReceipts.EncodeIndex(0, buf) haveEncodeIndex = buf.Bytes() if !bytes.Equal(have, haveEncodeIndex) { t.Errorf("BinaryMarshal and EncodeIndex mismatch, got %x want %x", have, haveEncodeIndex) } - altFeeWant := common.FromHex("02f901c58001b9010000000000000010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000500000000000000000000000000000000000014000000000000000000000000000000000000000000000000000000000000000000000000000010000080000000000000000000004000000000000000000000000000040000000000000000000000000000800000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000f8bef85d940000000000000000000000000000000000000011f842a0000000000000000000000000000000000000000000000000000000000000deada0000000000000000000000000000000000000000000000000000000000000beef830100fff85d940000000000000000000000000000000000000111f842a0000000000000000000000000000000000000000000000000000000000000deada0000000000000000000000000000000000000000000000000000000000000beef830100ff") - if !bytes.Equal(have, altFeeWant) { - t.Errorf("encoded RLP mismatch, got %x want %x", have, altFeeWant) + morphTxWant := common.FromHex("7ff901c58001b9010000000000000010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000500000000000000000000000000000000000014000000000000000000000000000000000000000000000000000000000000000000000000000010000080000000000000000000004000000000000000000000000000040000000000000000000000000000800000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000f8bef85d940000000000000000000000000000000000000011f842a0000000000000000000000000000000000000000000000000000000000000deada0000000000000000000000000000000000000000000000000000000000000beef830100fff85d940000000000000000000000000000000000000111f842a0000000000000000000000000000000000000000000000000000000000000deada0000000000000000000000000000000000000000000000000000000000000beef830100ff") + if !bytes.Equal(have, morphTxWant) { + t.Errorf("encoded RLP mismatch, got %x want %x", have, morphTxWant) } } @@ -593,3 +753,572 @@ func clearComputedFieldsOnLog(t *testing.T, log *Log) { log.TxIndex = math.MaxUint32 log.Index = math.MaxUint32 } + +// TestMorphTxReceiptStorageEncoding tests the storage encoding/decoding of MorphTx receipts +// with Version, Reference, and Memo fields. +func TestMorphTxReceiptStorageEncoding(t *testing.T) { + tests := []struct { + name string + receipt *Receipt + }{ + { + name: "MorphTx V1 with all fields", + receipt: morphTxV1Receipt, + }, + { + name: "MorphTx V0 (legacy format)", + receipt: morphTxV0Receipt, + }, + { + name: "MorphTx V1 with Reference only", + receipt: morphTxRefOnlyReceipt, + }, + { + name: "MorphTx V1 with Memo only", + receipt: morphTxMemoOnlyReceipt, + }, + { + name: "MorphTx V1 with empty Memo", + receipt: morphTxEmptyMemoReceipt, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Encode the receipt + enc, err := encodeAsStoredReceiptRLP(tc.receipt) + if err != nil { + t.Fatalf("Error encoding receipt: %v", err) + } + + // Decode the receipt + var dec ReceiptForStorage + if err := rlp.DecodeBytes(enc, &dec); err != nil { + t.Fatalf("Error decoding RLP receipt: %v", err) + } + + // Check consensus fields + if dec.Status != tc.receipt.Status { + t.Errorf("Status mismatch, want %v, have %v", tc.receipt.Status, dec.Status) + } + if dec.CumulativeGasUsed != tc.receipt.CumulativeGasUsed { + t.Errorf("CumulativeGasUsed mismatch, want %v, have %v", tc.receipt.CumulativeGasUsed, dec.CumulativeGasUsed) + } + if len(dec.Logs) != len(tc.receipt.Logs) { + t.Errorf("Logs count mismatch, want %v, have %v", len(tc.receipt.Logs), len(dec.Logs)) + } + + // Check MorphTx fields + if dec.Version != tc.receipt.Version { + t.Errorf("Version mismatch, want %v, have %v", tc.receipt.Version, dec.Version) + } + if !compareReference(dec.Reference, tc.receipt.Reference) { + t.Errorf("Reference mismatch, want %v, have %v", tc.receipt.Reference, dec.Reference) + } + if !compareMemo(dec.Memo, tc.receipt.Memo) { + t.Errorf("Memo mismatch, want %v, have %v", tc.receipt.Memo, dec.Memo) + } + + // Check other MorphTx fields + if !compareFeeTokenID(dec.FeeTokenID, tc.receipt.FeeTokenID) { + t.Errorf("FeeTokenID mismatch, want %v, have %v", tc.receipt.FeeTokenID, dec.FeeTokenID) + } + if !compareBigInt(dec.L1Fee, tc.receipt.L1Fee) { + t.Errorf("L1Fee mismatch, want %v, have %v", tc.receipt.L1Fee, dec.L1Fee) + } + }) + } +} + +// Helper functions for comparing receipt fields that may have different nil/zero representations after RLP encoding +func compareReference(a, b *common.Reference) bool { + if a == nil && b == nil { + return true + } + if a == nil || b == nil { + // After RLP decode, nil becomes zero Reference + if a == nil { + return *b == common.Reference{} + } + return *a == common.Reference{} + } + return *a == *b +} + +func compareMemo(a, b *[]byte) bool { + if a == nil && b == nil { + return true + } + if a == nil || b == nil { + // After RLP decode, nil becomes empty slice + if a == nil { + return len(*b) == 0 + } + return len(*a) == 0 + } + return bytes.Equal(*a, *b) +} + +func compareFeeTokenID(a, b *uint16) bool { + if a == nil && b == nil { + return true + } + if a == nil || b == nil { + // After RLP decode, nil becomes 0 + if a == nil { + return *b == 0 + } + return *a == 0 + } + return *a == *b +} + +func compareBigInt(a, b *big.Int) bool { + if a == nil && b == nil { + return true + } + if a == nil || b == nil { + // After RLP decode, nil becomes 0 + if a == nil { + return b.Sign() == 0 + } + return a.Sign() == 0 + } + return a.Cmp(b) == 0 +} + +// TestMorphTxReceiptV8StorageEncoding tests V8 storage format specifically +func TestMorphTxReceiptV8StorageEncoding(t *testing.T) { + tests := []struct { + name string + receipt *Receipt + }{ + { + name: "MorphTx V1 with all fields", + receipt: morphTxV1Receipt, + }, + { + name: "MorphTx V0 (legacy format)", + receipt: morphTxV0Receipt, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Encode using V8 format + enc, err := encodeAsV8StoredReceiptRLP(tc.receipt) + if err != nil { + t.Fatalf("Error encoding receipt: %v", err) + } + + // Decode the receipt + var dec ReceiptForStorage + if err := rlp.DecodeBytes(enc, &dec); err != nil { + t.Fatalf("Error decoding RLP receipt: %v", err) + } + + // Check new fields + if dec.Version != tc.receipt.Version { + t.Errorf("Version mismatch, want %v, have %v", tc.receipt.Version, dec.Version) + } + if !compareReference(dec.Reference, tc.receipt.Reference) { + t.Errorf("Reference mismatch, want %v, have %v", tc.receipt.Reference, dec.Reference) + } + if !compareMemo(dec.Memo, tc.receipt.Memo) { + t.Errorf("Memo mismatch, want %v, have %v", tc.receipt.Memo, dec.Memo) + } + }) + } +} + +// TestMorphTxReceiptBackwardCompatibility tests that old format receipts (V3-V7) +// decode correctly with new fields having default values +func TestMorphTxReceiptBackwardCompatibility(t *testing.T) { + // Create a basic receipt without new fields + receipt := &Receipt{ + Status: ReceiptStatusSuccessful, + CumulativeGasUsed: 1000, + Logs: []*Log{ + { + Address: common.BytesToAddress([]byte{0x11}), + Topics: []common.Hash{common.HexToHash("dead")}, + Data: []byte{0x01}, + }, + }, + L1Fee: big.NewInt(100), + } + + tests := []struct { + name string + encode func(*Receipt) ([]byte, error) + }{ + {"V7StoredReceiptRLP", encodeAsV7StoredReceiptRLP}, + {"V6StoredReceiptRLP", encodeAsV6StoredReceiptRLP}, + {"V5StoredReceiptRLP", encodeAsV5StoredReceiptRLP}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + enc, err := tc.encode(receipt) + if err != nil { + t.Fatalf("Error encoding receipt: %v", err) + } + + var dec ReceiptForStorage + if err := rlp.DecodeBytes(enc, &dec); err != nil { + t.Fatalf("Error decoding RLP receipt: %v", err) + } + + // New fields should have default/zero values + if dec.Version != 0 { + t.Errorf("Version should be 0 for old format, got %v", dec.Version) + } + if dec.Reference != nil { + t.Errorf("Reference should be nil for old format, got %v", dec.Reference) + } + if dec.Memo != nil { + t.Errorf("Memo should be nil for old format, got %v", dec.Memo) + } + + // Original fields should be preserved + if dec.Status != receipt.Status { + t.Errorf("Status mismatch, want %v, have %v", receipt.Status, dec.Status) + } + if dec.CumulativeGasUsed != receipt.CumulativeGasUsed { + t.Errorf("CumulativeGasUsed mismatch, want %v, have %v", receipt.CumulativeGasUsed, dec.CumulativeGasUsed) + } + }) + } +} + +// TestMorphTxReceiptJSONMarshal tests JSON marshaling/unmarshaling of MorphTx receipts +func TestMorphTxReceiptJSONMarshal(t *testing.T) { + tests := []struct { + name string + receipt *Receipt + }{ + { + name: "MorphTx V1 with all fields", + receipt: morphTxV1Receipt, + }, + { + name: "MorphTx V0 (legacy format)", + receipt: morphTxV0Receipt, + }, + { + name: "MorphTx V1 with Reference only", + receipt: morphTxRefOnlyReceipt, + }, + { + name: "MorphTx V1 with Memo only", + receipt: morphTxMemoOnlyReceipt, + }, + { + name: "MorphTx V1 with empty Memo", + receipt: morphTxEmptyMemoReceipt, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Marshal to JSON + data, err := tc.receipt.MarshalJSON() + if err != nil { + t.Fatalf("Error marshaling receipt to JSON: %v", err) + } + + // Unmarshal from JSON + var dec Receipt + if err := dec.UnmarshalJSON(data); err != nil { + t.Fatalf("Error unmarshaling receipt from JSON: %v", err) + } + + // Check new fields + if dec.Version != tc.receipt.Version { + t.Errorf("Version mismatch, want %v, have %v", tc.receipt.Version, dec.Version) + } + if !reflect.DeepEqual(dec.Reference, tc.receipt.Reference) { + t.Errorf("Reference mismatch, want %v, have %v", tc.receipt.Reference, dec.Reference) + } + if !reflect.DeepEqual(dec.Memo, tc.receipt.Memo) { + t.Errorf("Memo mismatch, want %v, have %v", tc.receipt.Memo, dec.Memo) + } + + // Check core fields + if dec.Status != tc.receipt.Status { + t.Errorf("Status mismatch, want %v, have %v", tc.receipt.Status, dec.Status) + } + if dec.CumulativeGasUsed != tc.receipt.CumulativeGasUsed { + t.Errorf("CumulativeGasUsed mismatch, want %v, have %v", tc.receipt.CumulativeGasUsed, dec.CumulativeGasUsed) + } + if dec.Type != tc.receipt.Type { + t.Errorf("Type mismatch, want %v, have %v", tc.receipt.Type, dec.Type) + } + }) + } +} + +// TestDeriveFieldsWithMorphTx tests DeriveFields with MorphTx transactions +func TestDeriveFieldsWithMorphTx(t *testing.T) { + to := common.HexToAddress("0x1") + ref := common.HexToReference("0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890") + memo := []byte("derive fields test memo") + + // Create transactions including MorphTx + txs := Transactions{ + NewTx(&LegacyTx{ + Nonce: 1, + Value: big.NewInt(1), + Gas: 1, + GasPrice: big.NewInt(1), + }), + NewTx(&MorphTx{ + ChainID: big.NewInt(1), + Nonce: 2, + GasTipCap: big.NewInt(1), + GasFeeCap: big.NewInt(1), + Gas: 2, + To: &to, + Value: big.NewInt(2), + Version: MorphTxVersion1, + Reference: &ref, + Memo: &memo, + }), + NewTx(&MorphTx{ + ChainID: big.NewInt(1), + Nonce: 3, + GasTipCap: big.NewInt(1), + GasFeeCap: big.NewInt(1), + Gas: 3, + To: &to, + Value: big.NewInt(3), + Version: MorphTxVersion0, + FeeTokenID: 1, + }), + } + + // Create corresponding receipts + receipts := Receipts{ + &Receipt{ + Status: ReceiptStatusSuccessful, + CumulativeGasUsed: 1, + Logs: []*Log{{Address: common.BytesToAddress([]byte{0x11})}}, + TxHash: txs[0].Hash(), + GasUsed: 1, + }, + &Receipt{ + Status: ReceiptStatusSuccessful, + CumulativeGasUsed: 3, + Logs: []*Log{{Address: common.BytesToAddress([]byte{0x22})}}, + TxHash: txs[1].Hash(), + GasUsed: 2, + Version: MorphTxVersion1, + Reference: &ref, + Memo: &memo, + }, + &Receipt{ + Status: ReceiptStatusSuccessful, + CumulativeGasUsed: 6, + Logs: []*Log{{Address: common.BytesToAddress([]byte{0x33})}}, + TxHash: txs[2].Hash(), + GasUsed: 3, + Version: MorphTxVersion0, + }, + } + + // Test DeriveFields + number := big.NewInt(1) + blockTime := uint64(2) + hash := common.BytesToHash([]byte{0x03, 0x14}) + + clearComputedFieldsOnReceipts(t, receipts) + if err := receipts.DeriveFields(params.TestChainConfig, hash, number.Uint64(), blockTime, nil, txs); err != nil { + t.Fatalf("DeriveFields(...) = %v, want ", err) + } + + // Verify MorphTx receipts + for i := range receipts { + if receipts[i].Type != txs[i].Type() { + t.Errorf("receipts[%d].Type = %d, want %d", i, receipts[i].Type, txs[i].Type()) + } + if receipts[i].TxHash != txs[i].Hash() { + t.Errorf("receipts[%d].TxHash = %s, want %s", i, receipts[i].TxHash.String(), txs[i].Hash().String()) + } + if receipts[i].BlockHash != hash { + t.Errorf("receipts[%d].BlockHash = %s, want %s", i, receipts[i].BlockHash.String(), hash.String()) + } + if receipts[i].TransactionIndex != uint(i) { + t.Errorf("receipts[%d].TransactionIndex = %d, want %d", i, receipts[i].TransactionIndex, i) + } + } + + // Verify MorphTx V1 receipt preserves Version/Reference/Memo + if receipts[1].Version != MorphTxVersion1 { + t.Errorf("receipts[1].Version = %d, want %d", receipts[1].Version, MorphTxVersion1) + } + if !reflect.DeepEqual(receipts[1].Reference, &ref) { + t.Errorf("receipts[1].Reference = %v, want %v", receipts[1].Reference, &ref) + } + if !reflect.DeepEqual(receipts[1].Memo, &memo) { + t.Errorf("receipts[1].Memo = %v, want %v", receipts[1].Memo, &memo) + } + + // Verify MorphTx V0 receipt + if receipts[2].Version != MorphTxVersion0 { + t.Errorf("receipts[2].Version = %d, want %d", receipts[2].Version, MorphTxVersion0) + } +} + +// TestMorphTxReceiptVersionValues tests specific version values +func TestMorphTxReceiptVersionValues(t *testing.T) { + feeTokenID := uint16(1) + zeroRef := common.Reference{} + emptyMemo := []byte{} + tests := []struct { + name string + version uint8 + expectVersion uint8 + }{ + {"Version 0", MorphTxVersion0, 0}, + {"Version 1", MorphTxVersion1, 1}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + receipt := &Receipt{ + Status: ReceiptStatusSuccessful, + CumulativeGasUsed: 100, + Logs: []*Log{}, + Type: MorphTxType, + Version: tc.version, + FeeTokenID: &feeTokenID, + FeeRate: big.NewInt(100), + TokenScale: big.NewInt(18), + FeeLimit: big.NewInt(1000), + L1Fee: big.NewInt(50), + Reference: &zeroRef, // Use zero value for RLP compatibility + Memo: &emptyMemo, // Use empty for RLP compatibility + } + + // Encode + enc, err := encodeAsStoredReceiptRLP(receipt) + if err != nil { + t.Fatalf("Error encoding receipt: %v", err) + } + + // Decode + var dec ReceiptForStorage + if err := rlp.DecodeBytes(enc, &dec); err != nil { + t.Fatalf("Error decoding RLP receipt: %v", err) + } + + if dec.Version != tc.expectVersion { + t.Errorf("Version mismatch, want %v, have %v", tc.expectVersion, dec.Version) + } + }) + } +} + +// TestMorphTxReceiptMemoEdgeCases tests edge cases for Memo field +func TestMorphTxReceiptMemoEdgeCases(t *testing.T) { + feeTokenID := uint16(1) + zeroRef := common.Reference{} + tests := []struct { + name string + memo *[]byte + }{ + // Note: nil memo cannot be tested with storedReceiptRLP as RLP encoding changes the element count + {"empty memo", func() *[]byte { m := []byte{}; return &m }()}, + {"single byte memo", func() *[]byte { m := []byte{0x01}; return &m }()}, + {"max length memo", func() *[]byte { m := make([]byte, common.MaxMemoLength); return &m }()}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + receipt := &Receipt{ + Status: ReceiptStatusSuccessful, + CumulativeGasUsed: 100, + Logs: []*Log{}, + Type: MorphTxType, + Version: MorphTxVersion1, + Memo: tc.memo, + FeeTokenID: &feeTokenID, + FeeRate: big.NewInt(100), + TokenScale: big.NewInt(18), + FeeLimit: big.NewInt(1000), + L1Fee: big.NewInt(50), + Reference: &zeroRef, // Required for RLP compatibility + } + + // Encode + enc, err := encodeAsStoredReceiptRLP(receipt) + if err != nil { + t.Fatalf("Error encoding receipt: %v", err) + } + + // Decode + var dec ReceiptForStorage + if err := rlp.DecodeBytes(enc, &dec); err != nil { + t.Fatalf("Error decoding RLP receipt: %v", err) + } + + if !compareMemo(dec.Memo, tc.memo) { + t.Errorf("Memo mismatch, want %v, have %v", tc.memo, dec.Memo) + } + }) + } +} + +// TestMorphTxReceiptReferenceEdgeCases tests edge cases for Reference field +func TestMorphTxReceiptReferenceEdgeCases(t *testing.T) { + feeTokenID := uint16(1) + emptyMemo := []byte{} + zeroRef := common.Reference{} + fullRef := common.HexToReference("0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff") + partialRef := common.HexToReference("0x1234567890abcdef1234567890abcdef00000000000000000000000000000000") + + tests := []struct { + name string + reference *common.Reference + }{ + // Note: nil reference cannot be tested with storedReceiptRLP as RLP encoding changes the element count + {"zero reference", &zeroRef}, + {"full reference", &fullRef}, + {"partial reference", &partialRef}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + receipt := &Receipt{ + Status: ReceiptStatusSuccessful, + CumulativeGasUsed: 100, + Logs: []*Log{}, + Type: MorphTxType, + Version: MorphTxVersion1, + Reference: tc.reference, + FeeTokenID: &feeTokenID, + FeeRate: big.NewInt(100), + TokenScale: big.NewInt(18), + FeeLimit: big.NewInt(1000), + L1Fee: big.NewInt(50), + Memo: &emptyMemo, // Required for RLP compatibility + } + + // Encode + enc, err := encodeAsStoredReceiptRLP(receipt) + if err != nil { + t.Fatalf("Error encoding receipt: %v", err) + } + + // Decode + var dec ReceiptForStorage + if err := rlp.DecodeBytes(enc, &dec); err != nil { + t.Fatalf("Error decoding RLP receipt: %v", err) + } + + if !compareReference(dec.Reference, tc.reference) { + t.Errorf("Reference mismatch, want %v, have %v", tc.reference, dec.Reference) + } + }) + } +} diff --git a/core/types/transaction.go b/core/types/transaction.go index c164886e5..74eda4c44 100644 --- a/core/types/transaction.go +++ b/core/types/transaction.go @@ -33,17 +33,22 @@ import ( ) var ( - ErrInvalidSig = errors.New("invalid transaction v, r, s values") - ErrUnexpectedProtection = errors.New("transaction type does not supported EIP-155 protected signatures") - ErrInvalidTxType = errors.New("transaction type not valid in this context") - ErrCostNotSupported = errors.New("cost function alt fee transaction not support or use gasFee()") - ErrTxTypeNotSupported = errors.New("transaction type not supported") - ErrGasFeeCapTooLow = errors.New("fee cap less than base fee") - errEmptyTypedTx = errors.New("empty typed transaction bytes") - errShortTypedTx = errors.New("typed transaction too short") - errInvalidYParity = errors.New("'yParity' field must be 0 or 1") - errVYParityMismatch = errors.New("'v' and 'yParity' fields do not match") - errVYParityMissing = errors.New("missing 'yParity' or 'v' field in transaction") + ErrInvalidSig = errors.New("invalid transaction v, r, s values") + ErrUnexpectedProtection = errors.New("transaction type does not supported EIP-155 protected signatures") + ErrInvalidTxType = errors.New("transaction type not valid in this context") + ErrCostNotSupported = errors.New("cost function morph transaction not support or use gasFee()") + ErrTxTypeNotSupported = errors.New("transaction type not supported") + ErrGasFeeCapTooLow = errors.New("fee cap less than base fee") + ErrMemoTooLong = errors.New("memo exceeds maximum length of 64 bytes") + ErrMorphTxV0IllegalExtraParams = errors.New("illegal extra parameters of version 0 MorphTx") + ErrMorphTxV1IllegalExtraParams = errors.New("illegal extra parameters of version 1 MorphTx") + ErrMorphTxUnsupportedVersion = errors.New("unsupported MorphTx version") + ErrMorphTxV1NotYetActive = errors.New("MorphTx version 1 is not yet active (jade fork not reached)") + errEmptyTypedTx = errors.New("empty typed transaction bytes") + errShortTypedTx = errors.New("typed transaction too short") + errInvalidYParity = errors.New("'yParity' field must be 0 or 1") + errVYParityMismatch = errors.New("'v' and 'yParity' fields do not match") + errVYParityMissing = errors.New("missing 'yParity' or 'v' field in transaction") ) // Transaction types. @@ -55,7 +60,7 @@ const ( SetCodeTxType = 0x04 L1MessageTxType = 0x7E - AltFeeTxType = 0x7F + MorphTxType = 0x7F ) // Transaction is an Ethereum transaction. @@ -220,8 +225,8 @@ func (tx *Transaction) decodeTyped(b []byte) (TxData, error) { inner = new(L1MessageTx) case SetCodeTxType: inner = new(SetCodeTx) - case AltFeeTxType: - inner = new(AltFeeTx) + case MorphTxType: + inner = new(MorphTx) default: return nil, ErrTxTypeNotSupported } @@ -331,9 +336,14 @@ func (tx *Transaction) IsL1MessageTx() bool { return tx.Type() == L1MessageTxType } -// IsAltFeeTx returns true if the transaction is erc20 fee tx. -func (tx *Transaction) IsAltFeeTx() bool { - return tx.Type() == AltFeeTxType +// IsMorphTx returns true if the transaction is morph tx. +func (tx *Transaction) IsMorphTx() bool { + return tx.Type() == MorphTxType +} + +// IsMorphTxWithAltFee returns true if the transaction is morph tx with alt fee. +func (tx *Transaction) IsMorphTxWithAltFee() bool { + return tx.IsMorphTx() && tx.FeeTokenID() != 0 } // AsL1MessageTx casts the tx into an L1 cross-domain tx. @@ -353,33 +363,93 @@ func (tx *Transaction) L1MessageQueueIndex() uint64 { return tx.AsL1MessageTx().QueueIndex } -// AsAltFeeTx casts the tx into an erc20 fee tx. -func (tx *Transaction) AsAltFeeTx() *AltFeeTx { - if !tx.IsAltFeeTx() { +// AsMorphTx casts the tx into an morph tx. +func (tx *Transaction) AsMorphTx() *MorphTx { + if !tx.IsMorphTx() { return nil } - return tx.inner.(*AltFeeTx) + return tx.inner.(*MorphTx) } func (tx *Transaction) FeeTokenID() uint16 { - if !tx.IsAltFeeTx() { + if !tx.IsMorphTx() { return 0 } - return tx.AsAltFeeTx().FeeTokenID + return tx.AsMorphTx().FeeTokenID } func (tx *Transaction) FeeLimit() *big.Int { - if !tx.IsAltFeeTx() { + if !tx.IsMorphTx() { return big.NewInt(0) } - return tx.AsAltFeeTx().FeeLimit + return tx.AsMorphTx().FeeLimit +} + +// Version returns the version of the MorphTx, or 0 if not a MorphTx. +func (tx *Transaction) Version() uint8 { + if !tx.IsMorphTx() { + return 0 + } + return tx.AsMorphTx().Version +} + +// Reference returns the reference of the MorphTx, or nil if not a MorphTx. +func (tx *Transaction) Reference() *common.Reference { + if !tx.IsMorphTx() { + return nil + } + return tx.AsMorphTx().Reference +} + +// Memo returns the memo of the MorphTx, or nil if not a MorphTx. +func (tx *Transaction) Memo() *[]byte { + if !tx.IsMorphTx() { + return nil + } + return tx.AsMorphTx().Memo +} + +// ValidateMorphTxVersion validates the MorphTx version, memo length, and associated field requirements. +// Rules: +// - Memo must not exceed MaxMemoLength bytes +// - Version 0 (legacy format): FeeTokenID must be > 0, Reference and Memo must not be set +// - Version 1 (with Reference/Memo): FeeTokenID, Reference, Memo are all optional; +// if FeeTokenID is 0, FeeLimit must not be set +// - Other versions: not supported +// +// Returns nil if the transaction is not a MorphTx or if all checks pass. +func (tx *Transaction) ValidateMorphTxVersion() error { + if !tx.IsMorphTx() { + return nil + } + morphTx := tx.AsMorphTx() + + switch morphTx.Version { + case MorphTxVersion0: + // Version 0 requires FeeTokenID > 0 (legacy format used for alt-fee transactions) + if morphTx.FeeTokenID == 0 || + morphTx.Reference != nil && *morphTx.Reference != (common.Reference{}) || + morphTx.Memo != nil && len(*morphTx.Memo) > 0 { + return ErrMorphTxV0IllegalExtraParams + } + case MorphTxVersion1: + // Version 1: FeeTokenID, Reference, Memo are all optional + // If FeeTokenID is 0, FeeLimit must not be set + if morphTx.FeeTokenID == 0 && morphTx.FeeLimit != nil && morphTx.FeeLimit.Sign() != 0 { + return ErrMorphTxV1IllegalExtraParams + } + // Validate memo length + if morphTx.Memo != nil && len(*morphTx.Memo) > common.MaxMemoLength { + return ErrMemoTooLong + } + default: + return ErrMorphTxUnsupportedVersion + } + return nil } // Cost returns gas * gasPrice + value. func (tx *Transaction) Cost() *big.Int { - if tx.IsAltFeeTx() { - panic(ErrCostNotSupported) - } total := tx.GasFee() total.Add(total, tx.Value()) return total @@ -791,9 +861,30 @@ type Message struct { setCodeAuthorizations []SetCodeAuthorization feeTokenID uint16 feeLimit *big.Int -} - -func NewMessage(from common.Address, to *common.Address, nonce uint64, amount *big.Int, gasLimit uint64, gasPrice, gasFeeCap, gasTipCap *big.Int, feeTokenID uint16, feeLimit *big.Int, data []byte, accessList AccessList, authList []SetCodeAuthorization, isFake bool) Message { + version uint8 + reference *common.Reference + memo *[]byte +} + +func NewMessage( + from common.Address, + to *common.Address, + nonce uint64, + amount *big.Int, + gasLimit uint64, + gasPrice *big.Int, + gasFeeCap *big.Int, + gasTipCap *big.Int, + feeTokenID uint16, + feeLimit *big.Int, + version uint8, + reference *common.Reference, + memo *[]byte, + data []byte, + accessList AccessList, + authList []SetCodeAuthorization, + isFake bool, +) Message { return Message{ from: from, to: to, @@ -810,6 +901,9 @@ func NewMessage(from common.Address, to *common.Address, nonce uint64, amount *b isL1MessageTx: false, feeTokenID: feeTokenID, feeLimit: feeLimit, + version: version, + reference: reference, + memo: memo, } } @@ -829,17 +923,23 @@ func (tx *Transaction) AsMessage(s Signer, baseFee *big.Int) (Message, error) { isL1MessageTx: tx.IsL1MessageTx(), setCodeAuthorizations: tx.SetCodeAuthorizations(), feeTokenID: tx.FeeTokenID(), + version: tx.Version(), + reference: tx.Reference(), + memo: tx.Memo(), } // If baseFee provided, set gasPrice to effectiveGasPrice. if baseFee != nil { msg.gasPrice = math.BigMin(msg.gasPrice.Add(msg.gasTipCap, baseFee), msg.gasFeeCap) } - if tx.IsAltFeeTx() && tx.FeeTokenID() == 0 { - return msg, errors.New("token id 0 not support") + + if err := tx.ValidateMorphTxVersion(); err != nil { + return Message{}, err } + if tx.FeeLimit() != nil { msg.feeLimit = tx.FeeLimit() } + var err error msg.from, err = Sender(s, tx) return msg, err @@ -860,6 +960,9 @@ func (m Message) IsL1MessageTx() bool { return m.isL1M func (m Message) SetCodeAuthorizations() []SetCodeAuthorization { return m.setCodeAuthorizations } func (m Message) FeeTokenID() uint16 { return m.feeTokenID } func (m Message) FeeLimit() *big.Int { return m.feeLimit } +func (m Message) Version() uint8 { return m.version } +func (m Message) Reference() *common.Reference { return m.reference } +func (m Message) Memo() *[]byte { return m.memo } // copyAddressPtr copies an address. func copyAddressPtr(a *common.Address) *common.Address { @@ -870,6 +973,25 @@ func copyAddressPtr(a *common.Address) *common.Address { return &cpy } +// copyReferencePtr copies a common.Reference and returns a pointer to the copy. +func copyReferencePtr(h *common.Reference) *common.Reference { + if h == nil { + return nil + } + cpy := *h + return &cpy +} + +// copyBytesPtr creates a deep copy of a byte slice pointer. +func copyBytesPtr(b *[]byte) *[]byte { + if b == nil { + return nil + } + cpy := make([]byte, len(*b)) + copy(cpy, *b) + return &cpy +} + type SkippedTransaction struct { Tx *Transaction Reason string diff --git a/core/types/transaction_marshalling.go b/core/types/transaction_marshalling.go index d7f43b193..bf5e72e08 100644 --- a/core/types/transaction_marshalling.go +++ b/core/types/transaction_marshalling.go @@ -62,9 +62,12 @@ type txJSON struct { Sender *common.Address `json:"sender,omitempty"` QueueIndex *hexutil.Uint64 `json:"queueIndex,omitempty"` - // Alt fee transaction fields: - FeeTokenID hexutil.Uint16 `json:"feeTokenID"` - FeeLimit *hexutil.Big `json:"feeLimit"` + // MorphTx transaction fields: + FeeTokenID hexutil.Uint16 `json:"feeTokenID"` + FeeLimit *hexutil.Big `json:"feeLimit"` + Version *hexutil.Uint64 `json:"version"` + Reference *common.Reference `json:"reference"` + Memo *hexutil.Bytes `json:"memo"` } // yParityValue returns the YParity value from JSON. For backwards-compatibility reasons, @@ -189,7 +192,7 @@ func (tx *Transaction) MarshalJSON() ([]byte, error) { yparity := itx.V.Uint64() enc.YParity = (*hexutil.Uint64)(&yparity) - case *AltFeeTx: + case *MorphTx: enc.ChainID = (*hexutil.Big)(itx.ChainID) enc.Nonce = (*hexutil.Uint64)(&itx.Nonce) enc.To = tx.To() @@ -201,6 +204,13 @@ func (tx *Transaction) MarshalJSON() ([]byte, error) { enc.AccessList = &itx.AccessList enc.FeeTokenID = hexutil.Uint16(itx.FeeTokenID) enc.FeeLimit = (*hexutil.Big)(itx.FeeLimit) + // Only include V1 fields (version, reference, memo) for V1+ transactions + if itx.Version >= MorphTxVersion1 { + v := hexutil.Uint64(itx.Version) + enc.Version = &v + enc.Reference = (*common.Reference)(itx.Reference) + enc.Memo = (*hexutil.Bytes)(itx.Memo) + } enc.V = (*hexutil.Big)(itx.V) enc.R = (*hexutil.Big)(itx.R) enc.S = (*hexutil.Big)(itx.S) @@ -567,8 +577,8 @@ func (tx *Transaction) UnmarshalJSON(input []byte) error { } } - case AltFeeTxType: - var itx AltFeeTx + case MorphTxType: + var itx MorphTx inner = &itx if dec.ChainID == nil { return errors.New("missing required field 'chainId' in transaction") @@ -599,6 +609,11 @@ func (tx *Transaction) UnmarshalJSON(input []byte) error { itx.FeeTokenID = uint16(dec.FeeTokenID) itx.FeeLimit = (*big.Int)(dec.FeeLimit) itx.Value = (*big.Int)(dec.Value) + if dec.Version != nil { + itx.Version = uint8(*dec.Version) + } + itx.Reference = (*common.Reference)(dec.Reference) + itx.Memo = (*[]byte)(dec.Memo) if dec.Input == nil { return errors.New("missing required field 'input' in transaction") } diff --git a/core/types/transaction_signing.go b/core/types/transaction_signing.go index 767a4d62f..8204497ef 100644 --- a/core/types/transaction_signing.go +++ b/core/types/transaction_signing.go @@ -229,7 +229,7 @@ func newModernSigner(chainID *big.Int, fork forks.Fork) Signer { s.txtypes[SetCodeTxType] = struct{}{} } if fork >= forks.Emerald { - s.txtypes[AltFeeTxType] = struct{}{} + s.txtypes[MorphTxType] = struct{}{} } return s } diff --git a/core/types/transaction_test.go b/core/types/transaction_test.go index 28da3d85b..157d59928 100644 --- a/core/types/transaction_test.go +++ b/core/types/transaction_test.go @@ -20,6 +20,7 @@ import ( "bytes" "crypto/ecdsa" "encoding/json" + "errors" "fmt" "math/big" "math/rand" @@ -70,13 +71,78 @@ var ( NewEIP2930Signer(big.NewInt(1)), common.Hex2Bytes("c9519f4f2b30335884581971573fadf60c6204f59a911df35ee8a540456b266032f1e8e2c5dd761f9e4f88f41c8310aeaba26a8bfcdacfedfa12ec3862d3752101"), ) + + // MorphTx test fixtures + testMorphTxReference = common.HexToReference("0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef") + testMorphTxMemo = []byte("test memo data for morph tx") + + // MorphTx Version 0 (legacy format with alt fee) + emptyMorphTxV0 = NewTx(&MorphTx{ + ChainID: big.NewInt(1), + Nonce: 3, + GasTipCap: big.NewInt(1), + GasFeeCap: big.NewInt(10), + Gas: 25000, + To: &testAddr, + Value: big.NewInt(10), + Data: common.FromHex("5544"), + AccessList: nil, + FeeTokenID: 1, // V0 requires FeeTokenID > 0 + FeeLimit: big.NewInt(1000000), + Version: MorphTxVersion0, + }) + + // MorphTx Version 1 (with Reference and Memo) + emptyMorphTxV1 = NewTx(&MorphTx{ + ChainID: big.NewInt(1), + Nonce: 3, + GasTipCap: big.NewInt(1), + GasFeeCap: big.NewInt(10), + Gas: 25000, + To: &testAddr, + Value: big.NewInt(10), + Data: common.FromHex("5544"), + AccessList: nil, + FeeTokenID: 0, // V1 allows FeeTokenID = 0 + FeeLimit: big.NewInt(0), + Version: MorphTxVersion1, + Reference: &testMorphTxReference, + Memo: &testMorphTxMemo, + }) + + // MorphTx V1 with only Reference (no Memo) + morphTxV1RefOnly = NewTx(&MorphTx{ + ChainID: big.NewInt(1), + Nonce: 4, + GasTipCap: big.NewInt(2), + GasFeeCap: big.NewInt(20), + Gas: 30000, + To: &testAddr, + Value: big.NewInt(20), + Version: MorphTxVersion1, + Reference: &testMorphTxReference, + }) + + // MorphTx V1 with only Memo (no Reference) + morphTxV1MemoOnly = NewTx(&MorphTx{ + ChainID: big.NewInt(1), + Nonce: 5, + GasTipCap: big.NewInt(3), + GasFeeCap: big.NewInt(30), + Gas: 35000, + To: &testAddr, + Value: big.NewInt(30), + Version: MorphTxVersion1, + Memo: &testMorphTxMemo, + }) ) func TestDecodeEmptyTypedTx(t *testing.T) { input := []byte{0x80} var tx Transaction err := rlp.DecodeBytes(input, &tx) - if err != errEmptyTypedTx { + // The error should be either errEmptyTypedTx or errShortTypedTx + if err != errEmptyTypedTx && err != errShortTypedTx { t.Fatal("wrong error:", err) } } @@ -171,15 +237,17 @@ func TestEIP2930Signer(t *testing.T) { t.Errorf("test %d: wrong sig hash: got %x, want %x", i, sigHash, test.wantSignerHash) } sender, err := Sender(test.signer, test.tx) - if err != test.wantSenderErr { - t.Errorf("test %d: wrong Sender error %q", i, err) + // Use errors.Is for wrapped errors + if !errors.Is(err, test.wantSenderErr) { + t.Errorf("test %d: wrong Sender error %q, want %q", i, err, test.wantSenderErr) } if err == nil && sender != keyAddr { t.Errorf("test %d: wrong sender address %x", i, sender) } signedTx, err := SignTx(test.tx, test.signer, key) - if err != test.wantSignErr { - t.Fatalf("test %d: wrong SignTx error %q", i, err) + // Use errors.Is for wrapped errors + if !errors.Is(err, test.wantSignErr) { + t.Fatalf("test %d: wrong SignTx error %q, want %q", i, err, test.wantSignErr) } if signedTx != nil { if signedTx.Hash() != test.wantHash { @@ -412,15 +480,18 @@ func TestTransactionCoding(t *testing.T) { t.Fatalf("could not generate key: %v", err) } var ( - signer = NewEIP2930Signer(common.Big1) - addr = common.HexToAddress("0x0000000000000000000000000000000000000001") - recipient = common.HexToAddress("095e7baea6a6c7c4c2dfeb977efac326af552d87") - accesses = AccessList{{Address: addr, StorageKeys: []common.Hash{{0}}}} + signer = NewEIP2930Signer(common.Big1) + morphSigner = NewEmeraldSigner(common.Big1) // Signer for MorphTx + addr = common.HexToAddress("0x0000000000000000000000000000000000000001") + recipient = common.HexToAddress("095e7baea6a6c7c4c2dfeb977efac326af552d87") + accesses = AccessList{{Address: addr, StorageKeys: []common.Hash{{0}}}} ) - for i := uint64(0); i < 500; i++ { + morphTxRef := common.HexToReference("0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890") + morphTxMemo := []byte("test memo") + for i := uint64(0); i < 800; i++ { var txdata TxData var isL1MessageTx bool - switch i % 6 { + switch i % 8 { case 0: // Legacy tx. txdata = &LegacyTx{ @@ -479,13 +550,52 @@ func TestTransactionCoding(t *testing.T) { Data: []byte("abcdef"), Sender: addr, } + case 6: + // MorphTx V0 (legacy format with alt fee) + txdata = &MorphTx{ + ChainID: big.NewInt(1), + Nonce: i, + GasTipCap: big.NewInt(1), + GasFeeCap: big.NewInt(10), + Gas: 123457, + To: &recipient, + Value: big.NewInt(10), + Data: []byte("abcdef"), + AccessList: accesses, + FeeTokenID: 1, // V0 requires FeeTokenID > 0 + FeeLimit: big.NewInt(1000000), + Version: MorphTxVersion0, + } + case 7: + // MorphTx V1 (with Reference and Memo) + txdata = &MorphTx{ + ChainID: big.NewInt(1), + Nonce: i, + GasTipCap: big.NewInt(2), + GasFeeCap: big.NewInt(20), + Gas: 123457, + To: &recipient, + Value: big.NewInt(20), + Data: []byte("abcdef"), + AccessList: accesses, + FeeTokenID: 0, + FeeLimit: big.NewInt(0), + Version: MorphTxVersion1, + Reference: &morphTxRef, + Memo: &morphTxMemo, + } } var tx *Transaction // dont sign L1MessageTx if isL1MessageTx { tx = NewTx(txdata) } else { - tx, err = SignNewTx(key, signer, txdata) + // Use morphSigner for MorphTx types + txSigner := signer + if _, ok := txdata.(*MorphTx); ok { + txSigner = morphSigner + } + tx, err = SignNewTx(key, txSigner, txdata) if err != nil { t.Fatalf("could not sign transaction: %v", err) } @@ -566,3 +676,840 @@ func assertEqual(orig *Transaction, cpy *Transaction) error { } return nil } + +// ==================== MorphTx Tests ==================== + +// TestMorphTxSigHash tests the signature hash calculation for MorphTx V0 and V1. +// These hashes are stable and should not change - any change indicates a breaking change. +func TestMorphTxSigHash(t *testing.T) { + signer := NewEmeraldSigner(big.NewInt(1)) + + // Test V0 sigHash (legacy format) + v0SigHash := signer.Hash(emptyMorphTxV0) + t.Logf("MorphTx V0 sigHash: %s", v0SigHash.Hex()) + expectedV0Hash := common.HexToHash("0x88cdb3aa657406af62d0c9d752d3f496829487516d70ef1c6172bd69c2ab9c4a") + if v0SigHash != expectedV0Hash { + t.Errorf("MorphTx V0 sigHash mismatch, got %s, want %s", v0SigHash.Hex(), expectedV0Hash.Hex()) + } + + // Test V1 sigHash (with Reference/Memo) + v1SigHash := signer.Hash(emptyMorphTxV1) + t.Logf("MorphTx V1 sigHash: %s", v1SigHash.Hex()) + expectedV1Hash := common.HexToHash("0xe80adb944285a68b17dfcf50e95e90f19a7ce23352563786f3732bf07be8f8cf") + if v1SigHash != expectedV1Hash { + t.Errorf("MorphTx V1 sigHash mismatch, got %s, want %s", v1SigHash.Hex(), expectedV1Hash.Hex()) + } + + // Ensure V0 and V1 sigHashes are different + if v0SigHash == v1SigHash { + t.Error("MorphTx V0 and V1 sigHashes should be different") + } +} + +// TestMorphTxSigner tests signature operations on MorphTx. +func TestMorphTxSigner(t *testing.T) { + key, _ := crypto.HexToECDSA("b71c71a67e1177ad4e901695e1b4b9ee17ae16c6668d313eac2f96dbcda3f291") + keyAddr := crypto.PubkeyToAddress(key.PublicKey) + signer := NewEmeraldSigner(big.NewInt(1)) + + tests := []struct { + name string + tx *Transaction + wantSenderErr error + }{ + { + name: "MorphTx V0 unsigned", + tx: emptyMorphTxV0, + wantSenderErr: ErrInvalidSig, + }, + { + name: "MorphTx V1 unsigned", + tx: emptyMorphTxV1, + wantSenderErr: ErrInvalidSig, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Test unsigned tx returns expected error + _, err := Sender(signer, tc.tx) + if !errors.Is(err, tc.wantSenderErr) { + t.Errorf("Sender error mismatch, got %v, want %v (using errors.Is)", err, tc.wantSenderErr) + } + + // Sign the tx + signedTx, err := SignTx(tc.tx, signer, key) + if err != nil { + t.Fatalf("SignTx failed: %v", err) + } + + // Verify sender + sender, err := Sender(signer, signedTx) + if err != nil { + t.Fatalf("Sender failed after signing: %v", err) + } + if sender != keyAddr { + t.Errorf("Sender mismatch, got %s, want %s", sender.Hex(), keyAddr.Hex()) + } + + // Verify hash is stable after signing + hash1 := signedTx.Hash() + hash2 := signedTx.Hash() + if hash1 != hash2 { + t.Error("Hash should be stable") + } + }) + } +} + +// TestMorphTxEncoding tests RLP and JSON encoding/decoding for MorphTx. +func TestMorphTxEncoding(t *testing.T) { + key, _ := crypto.GenerateKey() + signer := NewEmeraldSigner(big.NewInt(1)) + ref := common.HexToReference("0x1111111111111111111111111111111111111111111111111111111111111111") + memo := []byte("encoding test memo") + + zeroRef := common.Reference{} + emptyMemo := []byte{} + + tests := []struct { + name string + tx *MorphTx + }{ + { + name: "V0 basic", + tx: &MorphTx{ + ChainID: big.NewInt(1), + Nonce: 1, + GasTipCap: big.NewInt(1), + GasFeeCap: big.NewInt(10), + Gas: 21000, + To: &testAddr, + Value: big.NewInt(100), + FeeTokenID: 1, + FeeLimit: big.NewInt(1000), + Version: MorphTxVersion0, + }, + }, + { + name: "V1 with all fields", + tx: &MorphTx{ + ChainID: big.NewInt(1), + Nonce: 2, + GasTipCap: big.NewInt(2), + GasFeeCap: big.NewInt(20), + Gas: 21000, + To: &testAddr, + Value: big.NewInt(200), + FeeTokenID: 0, + Version: MorphTxVersion1, + Reference: &ref, + Memo: &memo, + }, + }, + { + name: "V1 with Reference only", + tx: &MorphTx{ + ChainID: big.NewInt(1), + Nonce: 3, + GasTipCap: big.NewInt(1), + GasFeeCap: big.NewInt(10), + Gas: 21000, + To: &testAddr, + Value: big.NewInt(0), + Version: MorphTxVersion1, + Reference: &ref, + Memo: &emptyMemo, // V1 requires Memo field for RLP encoding + }, + }, + { + name: "V1 with Memo only", + tx: &MorphTx{ + ChainID: big.NewInt(1), + Nonce: 4, + GasTipCap: big.NewInt(1), + GasFeeCap: big.NewInt(10), + Gas: 21000, + To: &testAddr, + Value: big.NewInt(0), + Version: MorphTxVersion1, + Reference: &zeroRef, // V1 requires Reference field for RLP encoding + Memo: &memo, + }, + }, + { + name: "V1 with empty Memo", + tx: &MorphTx{ + ChainID: big.NewInt(1), + Nonce: 5, + GasTipCap: big.NewInt(1), + GasFeeCap: big.NewInt(10), + Gas: 21000, + To: &testAddr, + Value: big.NewInt(0), + Version: MorphTxVersion1, + Reference: &zeroRef, // V1 requires Reference field for RLP encoding + Memo: &emptyMemo, + }, + }, + { + name: "V1 with max length Memo", + tx: &MorphTx{ + ChainID: big.NewInt(1), + Nonce: 6, + GasTipCap: big.NewInt(1), + GasFeeCap: big.NewInt(10), + Gas: 21000, + To: &testAddr, + Value: big.NewInt(0), + Version: MorphTxVersion1, + Reference: &zeroRef, // V1 requires Reference field for RLP encoding + Memo: func() *[]byte { m := make([]byte, common.MaxMemoLength); return &m }(), + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + tx := NewTx(tc.tx) + signedTx, err := SignTx(tx, signer, key) + if err != nil { + t.Fatalf("SignTx failed: %v", err) + } + + // Test RLP encoding/decoding + parsedTx, err := encodeDecodeBinary(signedTx) + if err != nil { + t.Fatalf("RLP encode/decode failed: %v", err) + } + if parsedTx.Hash() != signedTx.Hash() { + t.Error("Hash mismatch after RLP encode/decode") + } + if parsedTx.Version() != signedTx.Version() { + t.Errorf("Version mismatch, got %d, want %d", parsedTx.Version(), signedTx.Version()) + } + if !reflect.DeepEqual(parsedTx.Reference(), signedTx.Reference()) { + t.Error("Reference mismatch after RLP encode/decode") + } + if !compareMemoPtr(parsedTx.Memo(), signedTx.Memo()) { + t.Error("Memo mismatch after RLP encode/decode") + } + + // Test JSON encoding/decoding + parsedTx, err = encodeDecodeJSON(signedTx) + if err != nil { + t.Fatalf("JSON encode/decode failed: %v", err) + } + if parsedTx.Hash() != signedTx.Hash() { + t.Error("Hash mismatch after JSON encode/decode") + } + if parsedTx.Version() != signedTx.Version() { + t.Errorf("Version mismatch after JSON, got %d, want %d", parsedTx.Version(), signedTx.Version()) + } + }) + } +} + +// TestMorphTxValidation tests ValidateMorphTxVersion (which includes memo validation). +func TestMorphTxValidation(t *testing.T) { + t.Run("ValidateMorphTxVersion", func(t *testing.T) { + ref := common.HexToReference("0x1111111111111111111111111111111111111111111111111111111111111111") + emptyRef := common.Reference{} + memo := []byte("test memo") + emptyMemo := []byte{} + feeLimit := big.NewInt(1000) + + tests := []struct { + name string + version uint8 + feeTokenID uint16 + feeLimit *big.Int + reference *common.Reference + memo *[]byte + wantErr error + }{ + // Memo validation tests + {"nil memo", MorphTxVersion1, 0, nil, nil, nil, nil}, + {"empty memo", MorphTxVersion1, 0, nil, nil, func() *[]byte { m := []byte{}; return &m }(), nil}, + {"valid memo", MorphTxVersion1, 0, nil, nil, func() *[]byte { m := []byte("hello"); return &m }(), nil}, + {"max length memo", MorphTxVersion1, 0, nil, nil, func() *[]byte { m := make([]byte, common.MaxMemoLength); return &m }(), nil}, + {"over max length memo", MorphTxVersion1, 0, nil, nil, func() *[]byte { m := make([]byte, common.MaxMemoLength+1); return &m }(), ErrMemoTooLong}, + // Version 0 tests + {"V0 with FeeTokenID > 0", MorphTxVersion0, 1, nil, nil, nil, nil}, + {"V0 with FeeTokenID = 0", MorphTxVersion0, 0, nil, nil, nil, ErrMorphTxV0IllegalExtraParams}, + {"V0 with Reference", MorphTxVersion0, 1, nil, &ref, nil, ErrMorphTxV0IllegalExtraParams}, + {"V0 with empty Reference", MorphTxVersion0, 1, nil, &emptyRef, nil, nil}, + {"V0 with Memo", MorphTxVersion0, 1, nil, nil, &memo, ErrMorphTxV0IllegalExtraParams}, + {"V0 with empty Memo", MorphTxVersion0, 1, nil, nil, &emptyMemo, nil}, + {"V0 with Reference and Memo", MorphTxVersion0, 1, nil, &ref, &memo, ErrMorphTxV0IllegalExtraParams}, + // Version 1 tests + {"V1 with FeeTokenID = 0", MorphTxVersion1, 0, nil, nil, nil, nil}, + {"V1 with FeeTokenID > 0", MorphTxVersion1, 1, nil, nil, nil, nil}, + {"V1 with Reference", MorphTxVersion1, 0, nil, &ref, nil, nil}, + {"V1 with Memo", MorphTxVersion1, 0, nil, nil, &memo, nil}, + {"V1 with Reference and Memo", MorphTxVersion1, 1, nil, &ref, &memo, nil}, + {"V1 FeeTokenID=0 with FeeLimit", MorphTxVersion1, 0, feeLimit, nil, nil, ErrMorphTxV1IllegalExtraParams}, + {"V1 FeeTokenID>0 with FeeLimit", MorphTxVersion1, 1, feeLimit, nil, nil, nil}, + {"V1 FeeTokenID=0 with zero FeeLimit", MorphTxVersion1, 0, big.NewInt(0), nil, nil, nil}, + // Unsupported versions + {"Unsupported version 2", 2, 0, nil, nil, nil, ErrMorphTxUnsupportedVersion}, + {"Unsupported version 255", 255, 1, nil, nil, nil, ErrMorphTxUnsupportedVersion}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + tx := NewTx(&MorphTx{ + ChainID: big.NewInt(1), + Nonce: 1, + GasTipCap: big.NewInt(1), + GasFeeCap: big.NewInt(10), + Gas: 21000, + To: &testAddr, + Version: tc.version, + FeeTokenID: tc.feeTokenID, + FeeLimit: tc.feeLimit, + Reference: tc.reference, + Memo: tc.memo, + }) + err := tx.ValidateMorphTxVersion() + if err != tc.wantErr { + t.Errorf("ValidateMorphTxVersion error mismatch, got %v, want %v", err, tc.wantErr) + } + }) + } + }) + + t.Run("Non-MorphTx validation", func(t *testing.T) { + // ValidateMorphTxVersion on non-MorphTx should return nil + legacyTx := NewTx(&LegacyTx{ + Nonce: 1, + GasPrice: big.NewInt(1), + Gas: 21000, + To: &testAddr, + }) + if err := legacyTx.ValidateMorphTxVersion(); err != nil { + t.Errorf("ValidateMorphTxVersion on LegacyTx should return nil, got %v", err) + } + }) +} + +// TestMorphTxAsMessage tests that AsMessage calls ValidateMorphTxVersion +// and correctly rejects invalid MorphTx while accepting valid ones. +func TestMorphTxAsMessage(t *testing.T) { + key, _ := crypto.GenerateKey() + signer := NewEmeraldSigner(big.NewInt(1)) + ref := common.HexToReference("0x1111111111111111111111111111111111111111111111111111111111111111") + memo := []byte("test memo") + longMemo := make([]byte, common.MaxMemoLength+1) + baseFee := big.NewInt(7) + + tests := []struct { + name string + txdata *MorphTx + wantErr error + }{ + // --- Valid cases --- + { + name: "V0 with FeeTokenID > 0 → success", + txdata: &MorphTx{ + ChainID: big.NewInt(1), + Nonce: 1, + GasTipCap: big.NewInt(1), + GasFeeCap: big.NewInt(10), + Gas: 21000, + To: &testAddr, + Value: big.NewInt(0), + Version: MorphTxVersion0, + FeeTokenID: 1, + FeeLimit: big.NewInt(1000), + }, + wantErr: nil, + }, + { + name: "V1 with FeeTokenID == 0, Reference + Memo → success", + txdata: &MorphTx{ + ChainID: big.NewInt(1), + Nonce: 3, + GasTipCap: big.NewInt(1), + GasFeeCap: big.NewInt(10), + Gas: 21000, + To: &testAddr, + Value: big.NewInt(0), + Version: MorphTxVersion1, + FeeTokenID: 0, + Reference: &ref, + Memo: &memo, + }, + wantErr: nil, + }, + { + name: "V1 with FeeTokenID > 0, Reference → success", + txdata: &MorphTx{ + ChainID: big.NewInt(1), + Nonce: 4, + GasTipCap: big.NewInt(1), + GasFeeCap: big.NewInt(10), + Gas: 21000, + To: &testAddr, + Value: big.NewInt(0), + Version: MorphTxVersion1, + FeeTokenID: 1, + FeeLimit: big.NewInt(500), + Reference: &ref, + }, + wantErr: nil, + }, + // --- V0 rejection cases --- + { + name: "V0 with FeeTokenID == 0 → ErrMorphTxV0IllegalExtraParams", + txdata: &MorphTx{ + ChainID: big.NewInt(1), + Nonce: 10, + GasTipCap: big.NewInt(1), + GasFeeCap: big.NewInt(10), + Gas: 21000, + To: &testAddr, + Value: big.NewInt(0), + Version: MorphTxVersion0, + FeeTokenID: 0, + }, + wantErr: ErrMorphTxV0IllegalExtraParams, + }, + { + name: "V0 with Reference → ErrMorphTxV0IllegalExtraParams", + txdata: &MorphTx{ + ChainID: big.NewInt(1), + Nonce: 11, + GasTipCap: big.NewInt(1), + GasFeeCap: big.NewInt(10), + Gas: 21000, + To: &testAddr, + Value: big.NewInt(0), + Version: MorphTxVersion0, + FeeTokenID: 1, + FeeLimit: big.NewInt(100), + Reference: &ref, + }, + wantErr: ErrMorphTxV0IllegalExtraParams, + }, + { + name: "V0 with Memo → ErrMorphTxV0IllegalExtraParams", + txdata: &MorphTx{ + ChainID: big.NewInt(1), + Nonce: 12, + GasTipCap: big.NewInt(1), + GasFeeCap: big.NewInt(10), + Gas: 21000, + To: &testAddr, + Value: big.NewInt(0), + Version: MorphTxVersion0, + FeeTokenID: 1, + FeeLimit: big.NewInt(100), + Memo: &memo, + }, + wantErr: ErrMorphTxV0IllegalExtraParams, + }, + // --- V1 rejection cases --- + { + name: "V1 FeeTokenID=0 + FeeLimit>0 → ErrMorphTxV1IllegalExtraParams", + txdata: &MorphTx{ + ChainID: big.NewInt(1), + Nonce: 20, + GasTipCap: big.NewInt(1), + GasFeeCap: big.NewInt(10), + Gas: 21000, + To: &testAddr, + Value: big.NewInt(0), + Version: MorphTxVersion1, + FeeTokenID: 0, + FeeLimit: big.NewInt(999), + }, + wantErr: ErrMorphTxV1IllegalExtraParams, + }, + { + name: "V1 memo too long → ErrMemoTooLong", + txdata: &MorphTx{ + ChainID: big.NewInt(1), + Nonce: 21, + GasTipCap: big.NewInt(1), + GasFeeCap: big.NewInt(10), + Gas: 21000, + To: &testAddr, + Value: big.NewInt(0), + Version: MorphTxVersion1, + FeeTokenID: 0, + Memo: &longMemo, + }, + wantErr: ErrMemoTooLong, + }, + // --- Unsupported version --- + { + name: "unsupported version 255 → ErrMorphTxUnsupportedVersion", + txdata: &MorphTx{ + ChainID: big.NewInt(1), + Nonce: 30, + GasTipCap: big.NewInt(1), + GasFeeCap: big.NewInt(10), + Gas: 21000, + To: &testAddr, + Value: big.NewInt(0), + Version: 255, + FeeTokenID: 1, + }, + wantErr: ErrMorphTxUnsupportedVersion, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + signedTx, err := SignNewTx(key, signer, tc.txdata) + if err != nil { + t.Fatalf("SignNewTx failed: %v", err) + } + + msg, err := signedTx.AsMessage(signer, baseFee) + if !errors.Is(err, tc.wantErr) { + t.Fatalf("AsMessage error mismatch: got %v, want %v", err, tc.wantErr) + } + if err == nil { + // Verify message fields are correctly populated + if msg.FeeTokenID() != tc.txdata.FeeTokenID { + t.Errorf("FeeTokenID mismatch: got %d, want %d", msg.FeeTokenID(), tc.txdata.FeeTokenID) + } + if msg.Version() != tc.txdata.Version { + t.Errorf("Version mismatch: got %d, want %d", msg.Version(), tc.txdata.Version) + } + } + }) + } + + // Non-MorphTx should always pass (not affected by the check) + t.Run("DynamicFeeTx → success (not affected)", func(t *testing.T) { + dynTx, err := SignNewTx(key, NewLondonSigner(big.NewInt(1)), &DynamicFeeTx{ + ChainID: big.NewInt(1), + Nonce: 5, + GasTipCap: big.NewInt(1), + GasFeeCap: big.NewInt(10), + Gas: 21000, + To: &testAddr, + Value: big.NewInt(0), + }) + if err != nil { + t.Fatalf("SignNewTx failed: %v", err) + } + if _, err := dynTx.AsMessage(NewLondonSigner(big.NewInt(1)), baseFee); err != nil { + t.Fatalf("AsMessage should succeed for DynamicFeeTx, got: %v", err) + } + }) +} + +// TestMorphTxAccessors tests accessor methods for MorphTx fields. +func TestMorphTxAccessors(t *testing.T) { + ref := common.HexToReference("0x2222222222222222222222222222222222222222222222222222222222222222") + memo := []byte("accessor test memo") + feeLimit := big.NewInt(999999) + + t.Run("MorphTx V0 accessors", func(t *testing.T) { + tx := NewTx(&MorphTx{ + ChainID: big.NewInt(1), + Nonce: 10, + GasTipCap: big.NewInt(5), + GasFeeCap: big.NewInt(50), + Gas: 30000, + To: &testAddr, + Value: big.NewInt(1000), + FeeTokenID: 42, + FeeLimit: feeLimit, + Version: MorphTxVersion0, + }) + + if !tx.IsMorphTx() { + t.Error("IsMorphTx should return true") + } + if !tx.IsMorphTxWithAltFee() { + t.Error("IsMorphTxWithAltFee should return true for V0 with FeeTokenID > 0") + } + if tx.Version() != MorphTxVersion0 { + t.Errorf("Version mismatch, got %d, want %d", tx.Version(), MorphTxVersion0) + } + if tx.FeeTokenID() != 42 { + t.Errorf("FeeTokenID mismatch, got %d, want 42", tx.FeeTokenID()) + } + if tx.FeeLimit().Cmp(feeLimit) != 0 { + t.Errorf("FeeLimit mismatch, got %v, want %v", tx.FeeLimit(), feeLimit) + } + if tx.Reference() != nil { + t.Error("Reference should be nil for V0") + } + if tx.Memo() != nil { + t.Error("Memo should be nil for V0") + } + }) + + t.Run("MorphTx V1 accessors", func(t *testing.T) { + tx := NewTx(&MorphTx{ + ChainID: big.NewInt(1), + Nonce: 20, + GasTipCap: big.NewInt(10), + GasFeeCap: big.NewInt(100), + Gas: 40000, + To: &testAddr, + Value: big.NewInt(2000), + FeeTokenID: 0, + Version: MorphTxVersion1, + Reference: &ref, + Memo: &memo, + }) + + if !tx.IsMorphTx() { + t.Error("IsMorphTx should return true") + } + if tx.IsMorphTxWithAltFee() { + t.Error("IsMorphTxWithAltFee should return false for FeeTokenID = 0") + } + if tx.Version() != MorphTxVersion1 { + t.Errorf("Version mismatch, got %d, want %d", tx.Version(), MorphTxVersion1) + } + if tx.FeeTokenID() != 0 { + t.Errorf("FeeTokenID mismatch, got %d, want 0", tx.FeeTokenID()) + } + if tx.Reference() == nil || *tx.Reference() != ref { + t.Error("Reference mismatch") + } + if tx.Memo() == nil || !bytes.Equal(*tx.Memo(), memo) { + t.Error("Memo mismatch") + } + }) + + t.Run("Non-MorphTx accessors", func(t *testing.T) { + legacyTx := NewTx(&LegacyTx{ + Nonce: 1, + GasPrice: big.NewInt(1), + Gas: 21000, + To: &testAddr, + }) + + if legacyTx.IsMorphTx() { + t.Error("IsMorphTx should return false for LegacyTx") + } + if legacyTx.IsMorphTxWithAltFee() { + t.Error("IsMorphTxWithAltFee should return false for LegacyTx") + } + if legacyTx.Version() != 0 { + t.Errorf("Version should return 0 for non-MorphTx, got %d", legacyTx.Version()) + } + if legacyTx.FeeTokenID() != 0 { + t.Errorf("FeeTokenID should return 0 for non-MorphTx, got %d", legacyTx.FeeTokenID()) + } + if legacyTx.Reference() != nil { + t.Error("Reference should return nil for non-MorphTx") + } + if legacyTx.Memo() != nil { + t.Error("Memo should return nil for non-MorphTx") + } + }) +} + +// TestMorphTxVersionBackwardCompatibility tests that V0 and V1 transactions +// can be correctly encoded, decoded, and signed. +func TestMorphTxVersionBackwardCompatibility(t *testing.T) { + key, _ := crypto.GenerateKey() + signer := NewEmeraldSigner(big.NewInt(1)) + + // Create V0 tx + v0Tx := NewTx(&MorphTx{ + ChainID: big.NewInt(1), + Nonce: 1, + GasTipCap: big.NewInt(1), + GasFeeCap: big.NewInt(10), + Gas: 21000, + To: &testAddr, + Value: big.NewInt(100), + FeeTokenID: 1, + FeeLimit: big.NewInt(1000), + Version: MorphTxVersion0, + }) + + // Create V1 tx with same basic params but different version/fields + ref := common.HexToReference("0x3333333333333333333333333333333333333333333333333333333333333333") + memo := []byte("backward compat test") + v1Tx := NewTx(&MorphTx{ + ChainID: big.NewInt(1), + Nonce: 1, + GasTipCap: big.NewInt(1), + GasFeeCap: big.NewInt(10), + Gas: 21000, + To: &testAddr, + Value: big.NewInt(100), + FeeTokenID: 1, + FeeLimit: big.NewInt(1000), + Version: MorphTxVersion1, + Reference: &ref, + Memo: &memo, + }) + + // Sign both transactions + signedV0, err := SignTx(v0Tx, signer, key) + if err != nil { + t.Fatalf("Failed to sign V0 tx: %v", err) + } + signedV1, err := SignTx(v1Tx, signer, key) + if err != nil { + t.Fatalf("Failed to sign V1 tx: %v", err) + } + + // Hashes should be different (different sigHash due to version) + if signedV0.Hash() == signedV1.Hash() { + t.Error("V0 and V1 transaction hashes should be different") + } + + // Both should be recoverable + sender0, err := Sender(signer, signedV0) + if err != nil { + t.Fatalf("Failed to recover V0 sender: %v", err) + } + sender1, err := Sender(signer, signedV1) + if err != nil { + t.Fatalf("Failed to recover V1 sender: %v", err) + } + if sender0 != sender1 { + t.Error("Senders should be the same") + } + + // Both should round-trip through RLP + decoded0, err := encodeDecodeBinary(signedV0) + if err != nil { + t.Fatalf("V0 RLP round-trip failed: %v", err) + } + if decoded0.Version() != MorphTxVersion0 { + t.Errorf("V0 version not preserved, got %d", decoded0.Version()) + } + + decoded1, err := encodeDecodeBinary(signedV1) + if err != nil { + t.Fatalf("V1 RLP round-trip failed: %v", err) + } + if decoded1.Version() != MorphTxVersion1 { + t.Errorf("V1 version not preserved, got %d", decoded1.Version()) + } + if decoded1.Reference() == nil || *decoded1.Reference() != ref { + t.Error("V1 Reference not preserved") + } + if decoded1.Memo() == nil || !bytes.Equal(*decoded1.Memo(), memo) { + t.Error("V1 Memo not preserved") + } +} + +// TestMorphTxReferenceEdgeCases tests edge cases for Reference field. +// Note: V1 MorphTx requires both Reference and Memo fields for RLP encoding. +func TestMorphTxReferenceEdgeCases(t *testing.T) { + key, _ := crypto.GenerateKey() + signer := NewEmeraldSigner(big.NewInt(1)) + + zeroRef := common.Reference{} + fullRef := common.HexToReference("0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff") + partialRef := common.HexToReference("0x1234567800000000000000000000000000000000000000000000000000000000") + emptyMemo := []byte{} + + tests := []struct { + name string + ref *common.Reference + }{ + {"zero reference", &zeroRef}, + {"full reference", &fullRef}, + {"partial reference", &partialRef}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + tx := NewTx(&MorphTx{ + ChainID: big.NewInt(1), + Nonce: 1, + GasTipCap: big.NewInt(1), + GasFeeCap: big.NewInt(10), + Gas: 21000, + To: &testAddr, + Version: MorphTxVersion1, + Reference: tc.ref, + Memo: &emptyMemo, // V1 requires Memo field for RLP encoding + }) + + signedTx, err := SignTx(tx, signer, key) + if err != nil { + t.Fatalf("SignTx failed: %v", err) + } + + decoded, err := encodeDecodeBinary(signedTx) + if err != nil { + t.Fatalf("RLP round-trip failed: %v", err) + } + + if !reflect.DeepEqual(decoded.Reference(), tc.ref) { + t.Errorf("Reference mismatch after round-trip, got %v, want %v", decoded.Reference(), tc.ref) + } + }) + } +} + +// TestMorphTxMemoEdgeCases tests edge cases for Memo field. +// Note: V1 MorphTx requires both Reference and Memo fields for RLP encoding. +func TestMorphTxMemoEdgeCases(t *testing.T) { + key, _ := crypto.GenerateKey() + signer := NewEmeraldSigner(big.NewInt(1)) + + zeroRef := common.Reference{} + + tests := []struct { + name string + memo *[]byte + }{ + {"empty memo", func() *[]byte { m := []byte{}; return &m }()}, + {"single byte", func() *[]byte { m := []byte{0x42}; return &m }()}, + {"max length", func() *[]byte { m := make([]byte, common.MaxMemoLength); return &m }()}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + tx := NewTx(&MorphTx{ + ChainID: big.NewInt(1), + Nonce: 1, + GasTipCap: big.NewInt(1), + GasFeeCap: big.NewInt(10), + Gas: 21000, + To: &testAddr, + Version: MorphTxVersion1, + Reference: &zeroRef, // V1 requires Reference field for RLP encoding + Memo: tc.memo, + }) + + signedTx, err := SignTx(tx, signer, key) + if err != nil { + t.Fatalf("SignTx failed: %v", err) + } + + decoded, err := encodeDecodeBinary(signedTx) + if err != nil { + t.Fatalf("RLP round-trip failed: %v", err) + } + + if !compareMemoPtr(decoded.Memo(), tc.memo) { + t.Errorf("Memo mismatch after round-trip, got %v, want %v", decoded.Memo(), tc.memo) + } + }) + } +} + +// Helper function to compare memo pointers +// Treats nil and empty as equivalent since RLP encodes both as 0x80 +func compareMemoPtr(a, b *[]byte) bool { + if a == nil && b == nil { + return true + } + if a == nil { + return len(*b) == 0 + } + if b == nil { + return len(*a) == 0 + } + return bytes.Equal(*a, *b) +} diff --git a/eth/backend.go b/eth/backend.go index 2a72465a8..409c04ad5 100644 --- a/eth/backend.go +++ b/eth/backend.go @@ -140,8 +140,8 @@ func New(stack *node.Node, config *ethconfig.Config) (*Ethereum, error) { if config.OverrideEmeraldTime != nil { overrides.EmeraldTime = config.OverrideEmeraldTime } - if config.OverrideMPTForkTime != nil { - overrides.MPTForkTime = config.OverrideMPTForkTime + if config.OverrideJadeForkTime != nil { + overrides.JadeForkTime = config.OverrideJadeForkTime } chainConfig, genesisHash, genesisErr := core.SetupGenesisBlockWithOverride(chainDb, config.Genesis, &overrides) if _, ok := genesisErr.(*params.ConfigCompatError); genesisErr != nil && !ok { diff --git a/eth/catalyst/l2_api.go b/eth/catalyst/l2_api.go index ec86be41f..464a72930 100644 --- a/eth/catalyst/l2_api.go +++ b/eth/catalyst/l2_api.go @@ -86,7 +86,7 @@ func (api *l2ConsensusAPI) AssembleL2Block(params AssembleL2BlockParams) (*Execu if params.Timestamp != nil { timestamp = time.Unix(int64(*params.Timestamp), 0) } - if api.eth.BlockChain().Config().IsMPTFork(uint64(timestamp.Unix())) == api.eth.BlockChain().Config().Morph.UseZktrie { + if api.eth.BlockChain().Config().IsJadeFork(uint64(timestamp.Unix())) == api.eth.BlockChain().Config().Morph.UseZktrie { return nil, fmt.Errorf("cannot assemble block for fork, useZKtrie: %v, please switch geth", api.eth.BlockChain().Config().Morph.UseZktrie) } newBlockResult, err := api.eth.Miner().BuildBlock(parent.Hash(), timestamp, transactions) diff --git a/eth/ethconfig/config.go b/eth/ethconfig/config.go index f7cdc9615..7e1f14549 100644 --- a/eth/ethconfig/config.go +++ b/eth/ethconfig/config.go @@ -225,8 +225,8 @@ type Config struct { // EmeraldTime override OverrideEmeraldTime *uint64 `toml:",omitempty"` - // MPTForkTime override - OverrideMPTForkTime *uint64 `toml:",omitempty"` + // JadeForkTime override + OverrideJadeForkTime *uint64 `toml:",omitempty"` } // CreateConsensusEngine creates a consensus engine for the given chain config. diff --git a/ethclient/ethclient.go b/ethclient/ethclient.go index 7fc7d9232..22f997b2e 100644 --- a/ethclient/ethclient.go +++ b/ethclient/ethclient.go @@ -371,6 +371,25 @@ func (ec *Client) GetSkippedTransaction(ctx context.Context, txHash common.Hash) return tx, ec.c.CallContext(ctx, &tx, "morph_getSkippedTransaction", txHash) } +// GetTransactionHashesByReference returns transactions for the given reference with pagination. +// Results are sorted by blockTimestamp and txIndex (ascending order). +// Parameters: +// - reference: the reference key to query +// - offset: pagination offset (default: 0) +// - limit: pagination limit (default: 100, max: 100) +func (ec *Client) GetTransactionHashesByReference(ctx context.Context, reference common.Reference, offset *hexutil.Uint64, limit *hexutil.Uint64) ([]rpc.ReferenceTransactionResult, error) { + var result []rpc.ReferenceTransactionResult + args := rpc.ReferenceQueryArgs{ + Reference: reference, + Offset: offset, + Limit: limit, + } + if err := ec.c.CallContext(ctx, &result, "morph_getTransactionHashesByReference", args); err != nil { + return nil, err + } + return result, nil +} + // GetBlockByNumberOrHash returns the requested block func (ec *Client) GetBlockByNumberOrHash(ctx context.Context, blockNrOrHash rpc.BlockNumberOrHash) (*types.BlockMore, error) { var raw json.RawMessage diff --git a/graphql/graphql.go b/graphql/graphql.go index 64890619d..ebd035430 100644 --- a/graphql/graphql.go +++ b/graphql/graphql.go @@ -243,7 +243,7 @@ func (t *Transaction) GasPrice(ctx context.Context) (hexutil.Big, error) { } } return hexutil.Big(*tx.GasPrice()), nil - case types.AltFeeTxType: + case types.MorphTxType: if t.block != nil { if baseFee, _ := t.block.BaseFeePerGas(ctx); baseFee != nil { return (hexutil.Big)(*math.BigMin(new(big.Int).Add(tx.GasTipCap(), baseFee.ToInt()), tx.GasFeeCap())), nil @@ -280,7 +280,7 @@ func (t *Transaction) MaxFeePerGas(ctx context.Context) (*hexutil.Big, error) { return nil, nil case types.DynamicFeeTxType, types.SetCodeTxType: return (*hexutil.Big)(tx.GasFeeCap()), nil - case types.AltFeeTxType: + case types.MorphTxType: return (*hexutil.Big)(tx.GasFeeCap()), nil default: return nil, nil @@ -297,7 +297,7 @@ func (t *Transaction) MaxPriorityFeePerGas(ctx context.Context) (*hexutil.Big, e return nil, nil case types.DynamicFeeTxType, types.SetCodeTxType: return (*hexutil.Big)(tx.GasTipCap()), nil - case types.AltFeeTxType: + case types.MorphTxType: return (*hexutil.Big)(tx.GasTipCap()), nil default: return nil, nil diff --git a/interfaces.go b/interfaces.go index 52c255258..78ead865a 100644 --- a/interfaces.go +++ b/interfaces.go @@ -142,9 +142,13 @@ type CallMsg struct { BlobGasFeeCap *big.Int BlobHashes []common.Hash - // For ERC20FeeType + // For MorphTxType FeeTokenID uint16 FeeLimit *big.Int + Version uint8 + Reference *common.Reference + Memo *[]byte + // For SetCodeTxType AuthorizationList []types.SetCodeAuthorization } diff --git a/internal/ethapi/api.go b/internal/ethapi/api.go index 6f85bd5d2..1fcd600ec 100644 --- a/internal/ethapi/api.go +++ b/internal/ethapi/api.go @@ -40,6 +40,7 @@ import ( "github.com/morph-l2/go-ethereum/consensus/misc" "github.com/morph-l2/go-ethereum/core" "github.com/morph-l2/go-ethereum/core/forkid" + "github.com/morph-l2/go-ethereum/core/rawdb" "github.com/morph-l2/go-ethereum/core/state" "github.com/morph-l2/go-ethereum/core/tracing" "github.com/morph-l2/go-ethereum/core/types" @@ -652,8 +653,8 @@ func (api *PublicBlockChainAPI) ChainId() (*hexutil.Big, error) { // morphExtension contains Morph-specific configuration fields (EIP-7910 extension) type morphExtension struct { - UseZktrie bool `json:"useZktrie"` - MPTForkTime *uint64 `json:"mptForkTime,omitempty"` + UseZktrie bool `json:"useZktrie"` + JadeForkTime *uint64 `json:"jadeForkTime,omitempty"` } // config represents a single fork configuration (EIP-7910) @@ -715,8 +716,8 @@ func (api *PublicBlockChainAPI) Config(ctx context.Context) (*configResponse, er // Morph extension morph := &morphExtension{ - UseZktrie: c.Morph.UseZktrie, - MPTForkTime: c.MPTForkTime, + UseZktrie: c.Morph.UseZktrie, + JadeForkTime: c.JadeForkTime, } return &config{ @@ -1547,9 +1548,12 @@ type RPCTransaction struct { Sender *common.Address `json:"sender,omitempty"` QueueIndex *hexutil.Uint64 `json:"queueIndex,omitempty"` - // Alt fee transaction fields: - FeeTokenID hexutil.Uint64 `json:"feeTokenID,omitempty"` - FeeLimit *hexutil.Big `json:"feeLimit,omitempty"` + // MorphTx fields: + FeeTokenID *hexutil.Uint16 `json:"feeTokenID,omitempty"` + FeeLimit *hexutil.Big `json:"feeLimit,omitempty"` + Version *hexutil.Uint64 `json:"version,omitempty"` + Reference *common.Reference `json:"reference,omitempty"` + Memo *hexutil.Bytes `json:"memo,omitempty"` } // NewRPCTransaction returns a transaction that will serialize to the RPC @@ -1600,7 +1604,7 @@ func NewRPCTransaction(tx *types.Transaction, blockHash common.Hash, blockNumber msg := tx.AsL1MessageTx() result.Sender = &msg.Sender result.QueueIndex = (*hexutil.Uint64)(&msg.QueueIndex) - case types.AltFeeTxType: + case types.MorphTxType: al := tx.AccessList() result.Accesses = &al result.ChainID = (*hexutil.Big)(tx.ChainId()) @@ -1614,8 +1618,16 @@ func NewRPCTransaction(tx *types.Transaction, blockHash common.Hash, blockNumber } else { result.GasPrice = (*hexutil.Big)(tx.GasFeeCap()) } - result.FeeTokenID = (hexutil.Uint64)(tx.FeeTokenID()) + feeTokenID := hexutil.Uint16(tx.FeeTokenID()) + result.FeeTokenID = &feeTokenID result.FeeLimit = (*hexutil.Big)(tx.FeeLimit()) + // Only include V1 fields (version, reference, memo) for V1+ transactions + if tx.Version() >= types.MorphTxVersion1 { + version := hexutil.Uint64(tx.Version()) + result.Version = &version + result.Reference = (*common.Reference)(tx.Reference()) + result.Memo = (*hexutil.Bytes)(tx.Memo()) + } case types.SetCodeTxType: al := tx.AccessList() yparity := hexutil.Uint64(v.Sign()) @@ -1985,11 +1997,13 @@ func marshalReceipt(ctx context.Context, b Backend, receipt *types.Receipt, bigb "l1Fee": (*hexutil.Big)(receipt.L1Fee), "feeRate": (*hexutil.Big)(receipt.FeeRate), "tokenScale": (*hexutil.Big)(receipt.TokenScale), + "feeTokenID": (*hexutil.Uint16)(receipt.FeeTokenID), "feeLimit": (*hexutil.Big)(receipt.FeeLimit), + "version": hexutil.Uint(receipt.Version), + "reference": (*common.Reference)(receipt.Reference), + "memo": (*hexutil.Bytes)(receipt.Memo), } - if receipt.FeeTokenID != nil { - fields["feeTokenID"] = hexutil.Uint16(*receipt.FeeTokenID) - } + // Assign the effective gas price paid if !b.ChainConfig().IsCurie(bigblock) { fields["effectiveGasPrice"] = hexutil.Uint64(tx.GasPrice().Uint64()) @@ -2449,3 +2463,70 @@ func toHexSlice(b [][]byte) []string { } return r } + +// PublicMorphAPI provides morph-specific RPC methods. +type PublicMorphAPI struct { + b Backend +} + +// NewPublicMorphAPI creates a new RPC service for morph-specific methods. +func NewPublicMorphAPI(b Backend) *PublicMorphAPI { + return &PublicMorphAPI{b} +} + +// GetTransactionHashesByReference returns transactions for the given reference with pagination. +// Results are sorted by blockTimestamp and txIndex (ascending order). +// Parameters: +// - args.reference: the reference key to query +// - args.offset: pagination offset (default: 0) +// - args.limit: pagination limit (default: 100, max: 100) +func (s *PublicMorphAPI) GetTransactionHashesByReference( + ctx context.Context, + args rpc.ReferenceQueryArgs, +) ( + []rpc.ReferenceTransactionResult, + error, +) { + // Set default values + offsetVal := uint64(0) + if args.Offset != nil { + offsetVal = uint64(*args.Offset) + } + limitVal := uint64(100) + if args.Limit != nil { + limitVal = uint64(*args.Limit) + } + + // Validate limit (max 100) + if limitVal > 100 { + return nil, errors.New("limit exceeds maximum value of 100") + } + // Cap offset to prevent linear scan DoS + if offsetVal > 10000 { + return nil, errors.New("offset exceeds maximum value of 10000") + } + + paginatedEntries, interrupted := rawdb.ReadReferenceIndexEntriesPaginatedWithInterrupt(s.b.ChainDb(), args.Reference, offsetVal, limitVal, ctx.Done()) + if interrupted { + return nil, ctx.Err() + } + if len(paginatedEntries) == 0 { + return nil, nil + } + + // Build result + result := make([]rpc.ReferenceTransactionResult, 0, len(paginatedEntries)) + for _, entry := range paginatedEntries { + blockNumber := rawdb.ReadTxLookupEntry(s.b.ChainDb(), entry.TxHash) + if blockNumber == nil { + continue + } + result = append(result, rpc.ReferenceTransactionResult{ + TransactionHash: entry.TxHash, + BlockNumber: hexutil.Uint64(*blockNumber), + BlockTimestamp: hexutil.Uint64(entry.BlockTimestamp), + TransactionIndex: hexutil.Uint64(entry.TxIndex), + }) + } + return result, nil +} diff --git a/internal/ethapi/api_morph_test.go b/internal/ethapi/api_morph_test.go new file mode 100644 index 000000000..e2c5c0e98 --- /dev/null +++ b/internal/ethapi/api_morph_test.go @@ -0,0 +1,629 @@ +package ethapi + +import ( + "context" + "crypto/ecdsa" + "math/big" + "testing" + + "github.com/morph-l2/go-ethereum/common" + "github.com/morph-l2/go-ethereum/common/hexutil" + "github.com/morph-l2/go-ethereum/core/rawdb" + "github.com/morph-l2/go-ethereum/core/types" + "github.com/morph-l2/go-ethereum/crypto" + "github.com/morph-l2/go-ethereum/ethdb" + "github.com/morph-l2/go-ethereum/params" + "github.com/morph-l2/go-ethereum/rpc" +) + +// mockMorphBackend is a minimal Backend mock that only implements ChainDb(). +// All other Backend methods are left unimplemented via embedding. +// This is sufficient because GetTransactionHashesByReference only uses ChainDb(). +type mockMorphBackend struct { + Backend // embed interface — calling unimplemented methods will panic + db ethdb.Database +} + +func (m *mockMorphBackend) ChainDb() ethdb.Database { return m.db } + +func makeTestRef(b byte) common.Reference { + var ref common.Reference + ref[0] = b + return ref +} + +func uint64Ptr(v uint64) *hexutil.Uint64 { + h := hexutil.Uint64(v) + return &h +} + +// TestGetTransactionHashesByReference_OffsetExceedsMax verifies that offset > 10000 is rejected. +func TestGetTransactionHashesByReference_OffsetExceedsMax(t *testing.T) { + // Validation errors are returned before ChainDb() is called, so nil backend is safe. + api := &PublicMorphAPI{b: nil} + ref := makeTestRef(0x01) + + tests := []struct { + name string + offset uint64 + want string + }{ + {"offset 10001", 10001, "offset exceeds maximum value of 10000"}, + {"offset max uint64", ^uint64(0), "offset exceeds maximum value of 10000"}, + {"offset 50000", 50000, "offset exceeds maximum value of 10000"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + args := rpc.ReferenceQueryArgs{ + Reference: ref, + Offset: uint64Ptr(tt.offset), + } + _, err := api.GetTransactionHashesByReference(context.Background(), args) + if err == nil { + t.Fatalf("expected error for offset=%d, got nil", tt.offset) + } + if err.Error() != tt.want { + t.Fatalf("expected error %q, got %q", tt.want, err.Error()) + } + }) + } +} + +// TestGetTransactionHashesByReference_LimitExceedsMax verifies that limit > 100 is rejected. +func TestGetTransactionHashesByReference_LimitExceedsMax(t *testing.T) { + api := &PublicMorphAPI{b: nil} + ref := makeTestRef(0x02) + + args := rpc.ReferenceQueryArgs{ + Reference: ref, + Limit: uint64Ptr(101), + } + _, err := api.GetTransactionHashesByReference(context.Background(), args) + if err == nil { + t.Fatal("expected error for limit=101, got nil") + } + if err.Error() != "limit exceeds maximum value of 100" { + t.Fatalf("unexpected error: %s", err.Error()) + } +} + +// TestGetTransactionHashesByReference_OffsetBoundary verifies boundary values. +func TestGetTransactionHashesByReference_OffsetBoundary(t *testing.T) { + db := rawdb.NewMemoryDatabase() + api := NewPublicMorphAPI(&mockMorphBackend{db: db}) + ref := makeTestRef(0x03) + + tests := []struct { + name string + offset *hexutil.Uint64 + limit *hexutil.Uint64 + expectErr bool + }{ + {"offset 0 (default)", nil, nil, false}, + {"offset 10000 (max allowed)", uint64Ptr(10000), uint64Ptr(1), false}, + {"offset 10001 (over max)", uint64Ptr(10001), uint64Ptr(1), true}, + {"offset 9999 limit 100", uint64Ptr(9999), uint64Ptr(100), false}, + {"limit 0", nil, uint64Ptr(0), false}, + {"limit 100 (max allowed)", nil, uint64Ptr(100), false}, + {"limit 101 (over max)", nil, uint64Ptr(101), true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + args := rpc.ReferenceQueryArgs{ + Reference: ref, + Offset: tt.offset, + Limit: tt.limit, + } + _, err := api.GetTransactionHashesByReference(context.Background(), args) + if tt.expectErr && err == nil { + t.Fatal("expected error, got nil") + } + if !tt.expectErr && err != nil { + t.Fatalf("expected no error, got: %s", err.Error()) + } + }) + } +} + +// TestGetTransactionHashesByReference_EmptyDB verifies that valid params on an empty DB return nil. +func TestGetTransactionHashesByReference_EmptyDB(t *testing.T) { + db := rawdb.NewMemoryDatabase() + api := NewPublicMorphAPI(&mockMorphBackend{db: db}) + ref := makeTestRef(0x04) + + args := rpc.ReferenceQueryArgs{ + Reference: ref, + Offset: uint64Ptr(0), + Limit: uint64Ptr(100), + } + result, err := api.GetTransactionHashesByReference(context.Background(), args) + if err != nil { + t.Fatalf("unexpected error: %s", err.Error()) + } + if result != nil { + t.Fatalf("expected nil result for empty db, got %d entries", len(result)) + } +} + +// --- setDefaults version-heuristic tests --- + +// mockSetDefaultsBackend implements the Backend methods required by setDefaults +// when Gas, Nonce, MaxFeePerGas, and MaxPriorityFeePerGas are pre-filled. +type mockSetDefaultsBackend struct { + Backend // embed — unimplemented methods panic if called + chainConfig *params.ChainConfig + header *types.Header +} + +func (m *mockSetDefaultsBackend) CurrentHeader() *types.Header { return m.header } +func (m *mockSetDefaultsBackend) ChainConfig() *params.ChainConfig { return m.chainConfig } + +func uint16VersionPtr(v uint8) *hexutil.Uint16 { + h := hexutil.Uint16(v) + return &h +} + +// TestSetDefaults_MorphTxVersionHeuristic tests the heuristic version defaulting logic +// in TransactionArgs.setDefaults(): +// - Version == nil + no V1 fields → V0 +// - Version == nil + Reference or Memo present → V1 +// - Explicit Version → use as-is +// - Before jade fork: V1-specific fields rejected; default is V0 +// - After jade fork: V1 fields allowed; heuristic picks V1 when present +func TestSetDefaults_MorphTxVersionHeuristic(t *testing.T) { + to := common.HexToAddress("0x1234567890abcdef1234567890abcdef12345678") + ref := common.HexToReference("0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + emptyRef := common.Reference{} + memo := hexutil.Bytes([]byte("test memo")) + emptyMemo := hexutil.Bytes([]byte{}) + + jadeForkTime := uint64(1000) + + // Helper to build a backend with jade fork at time 1000 + makeBackend := func(headTime uint64) *mockSetDefaultsBackend { + return &mockSetDefaultsBackend{ + chainConfig: ¶ms.ChainConfig{ + ChainID: big.NewInt(1), + CurieBlock: big.NewInt(0), // IsCurie = true so EIP-1559 path is used + JadeForkTime: &jadeForkTime, + }, + header: &types.Header{ + Number: big.NewInt(1), + Time: headTime, + BaseFee: big.NewInt(100), + }, + } + } + + // Common base args that avoid deep mocking (Gas, Nonce, gas fees pre-filled) + gas := hexutil.Uint64(21000) + nonce := hexutil.Uint64(0) + maxFee := (*hexutil.Big)(big.NewInt(100)) + tip := (*hexutil.Big)(big.NewInt(1)) + + baseArgs := func() TransactionArgs { + return TransactionArgs{ + To: &to, + Gas: &gas, + Nonce: &nonce, + MaxFeePerGas: maxFee, + MaxPriorityFeePerGas: tip, + } + } + + tests := []struct { + name string + headTime uint64 // head.Time (jade fork at 1000) + modify func(args *TransactionArgs) + wantVersion *uint16 // nil means version field should not be set (non-MorphTx) + wantErr bool + }{ + // === After jade fork (headTime >= 1000) === + { + name: "jade fork: FeeTokenID only → V0", + headTime: 1000, + modify: func(args *TransactionArgs) { + fid := hexutil.Uint16(1) + args.FeeTokenID = &fid + }, + wantVersion: uint16Ref(types.MorphTxVersion0), + }, + { + name: "jade fork: FeeTokenID + Reference → V1", + headTime: 1000, + modify: func(args *TransactionArgs) { + fid := hexutil.Uint16(1) + args.FeeTokenID = &fid + args.Reference = &ref + }, + wantVersion: uint16Ref(types.MorphTxVersion1), + }, + { + name: "jade fork: FeeTokenID + Memo → V1", + headTime: 1000, + modify: func(args *TransactionArgs) { + fid := hexutil.Uint16(1) + args.FeeTokenID = &fid + args.Memo = &memo + }, + wantVersion: uint16Ref(types.MorphTxVersion1), + }, + { + name: "jade fork: Reference only → V1", + headTime: 1000, + modify: func(args *TransactionArgs) { + args.Reference = &ref + }, + wantVersion: uint16Ref(types.MorphTxVersion1), + }, + { + name: "jade fork: Memo only → V1", + headTime: 1000, + modify: func(args *TransactionArgs) { + args.Memo = &memo + }, + wantVersion: uint16Ref(types.MorphTxVersion1), + }, + { + name: "jade fork: empty Reference + FeeTokenID → V0", + headTime: 1000, + modify: func(args *TransactionArgs) { + fid := hexutil.Uint16(1) + args.FeeTokenID = &fid + args.Reference = &emptyRef + }, + wantVersion: uint16Ref(types.MorphTxVersion0), + }, + { + name: "jade fork: empty Memo + FeeTokenID → V0", + headTime: 1000, + modify: func(args *TransactionArgs) { + fid := hexutil.Uint16(1) + args.FeeTokenID = &fid + args.Memo = &emptyMemo + }, + wantVersion: uint16Ref(types.MorphTxVersion0), + }, + { + name: "jade fork: explicit V0 → V0", + headTime: 1000, + modify: func(args *TransactionArgs) { + fid := hexutil.Uint16(1) + args.FeeTokenID = &fid + args.Version = uint16VersionPtr(types.MorphTxVersion0) + }, + wantVersion: uint16Ref(types.MorphTxVersion0), + }, + { + name: "jade fork: explicit V1 → V1", + headTime: 1000, + modify: func(args *TransactionArgs) { + args.Version = uint16VersionPtr(types.MorphTxVersion1) + }, + wantVersion: uint16Ref(types.MorphTxVersion1), + }, + { + name: "jade fork: no MorphTx fields → not MorphTx (version nil)", + headTime: 1000, + modify: func(args *TransactionArgs) {}, + wantVersion: nil, + }, + + // === Before jade fork (headTime < 1000) === + { + name: "pre-jade: FeeTokenID only → V0", + headTime: 500, + modify: func(args *TransactionArgs) { + fid := hexutil.Uint16(1) + args.FeeTokenID = &fid + }, + wantVersion: uint16Ref(types.MorphTxVersion0), + }, + { + name: "pre-jade: explicit V1 → rejected", + headTime: 500, + modify: func(args *TransactionArgs) { + args.Version = uint16VersionPtr(types.MorphTxVersion1) + }, + wantErr: true, + }, + { + name: "pre-jade: Reference → rejected (V1-only field before fork)", + headTime: 500, + modify: func(args *TransactionArgs) { + fid := hexutil.Uint16(1) + args.FeeTokenID = &fid + args.Reference = &ref + }, + wantErr: true, + }, + { + name: "pre-jade: Memo → rejected (V1-only field before fork)", + headTime: 500, + modify: func(args *TransactionArgs) { + fid := hexutil.Uint16(1) + args.FeeTokenID = &fid + args.Memo = &memo + }, + wantErr: true, + }, + { + name: "pre-jade: explicit V0 + FeeTokenID → V0 (ok)", + headTime: 500, + modify: func(args *TransactionArgs) { + fid := hexutil.Uint16(1) + args.FeeTokenID = &fid + args.Version = uint16VersionPtr(types.MorphTxVersion0) + }, + wantVersion: uint16Ref(types.MorphTxVersion0), + }, + + // === validateMorphTxVersion rejection cases === + { + name: "jade fork: explicit V0 + FeeTokenID=0 → rejected", + headTime: 1000, + modify: func(args *TransactionArgs) { + fid := hexutil.Uint16(0) + args.FeeTokenID = &fid + args.Version = uint16VersionPtr(types.MorphTxVersion0) + }, + wantErr: true, + }, + { + name: "jade fork: explicit V1 + FeeTokenID=0 + FeeLimit>0 → rejected", + headTime: 1000, + modify: func(args *TransactionArgs) { + fid := hexutil.Uint16(0) + args.FeeTokenID = &fid + args.FeeLimit = (*hexutil.Big)(big.NewInt(1000)) + args.Version = uint16VersionPtr(types.MorphTxVersion1) + }, + wantErr: true, + }, + { + name: "jade fork: auto V1 (Reference) + FeeTokenID=0 + FeeLimit>0 → rejected", + headTime: 1000, + modify: func(args *TransactionArgs) { + fid := hexutil.Uint16(0) + args.FeeTokenID = &fid + args.FeeLimit = (*hexutil.Big)(big.NewInt(1000)) + args.Reference = &ref + }, + wantErr: true, + }, + { + name: "jade fork: explicit V1 + FeeTokenID=0 + FeeLimit=nil → ok", + headTime: 1000, + modify: func(args *TransactionArgs) { + args.Version = uint16VersionPtr(types.MorphTxVersion1) + }, + wantVersion: uint16Ref(types.MorphTxVersion1), + }, + { + name: "jade fork: explicit V1 + FeeTokenID=0 + FeeLimit=0 → ok", + headTime: 1000, + modify: func(args *TransactionArgs) { + fid := hexutil.Uint16(0) + args.FeeTokenID = &fid + args.FeeLimit = (*hexutil.Big)(big.NewInt(0)) + args.Version = uint16VersionPtr(types.MorphTxVersion1) + }, + wantVersion: uint16Ref(types.MorphTxVersion1), + }, + { + name: "jade fork: auto V0 (FeeTokenID=1) + FeeLimit>0 → ok (V0 ignores FeeLimit)", + headTime: 1000, + modify: func(args *TransactionArgs) { + fid := hexutil.Uint16(1) + args.FeeTokenID = &fid + args.FeeLimit = (*hexutil.Big)(big.NewInt(1000)) + }, + wantVersion: uint16Ref(types.MorphTxVersion0), + }, + { + name: "jade fork: explicit V1 + FeeTokenID>0 + FeeLimit>0 → ok", + headTime: 1000, + modify: func(args *TransactionArgs) { + fid := hexutil.Uint16(1) + args.FeeTokenID = &fid + args.FeeLimit = (*hexutil.Big)(big.NewInt(1000)) + args.Version = uint16VersionPtr(types.MorphTxVersion1) + }, + wantVersion: uint16Ref(types.MorphTxVersion1), + }, + { + name: "jade fork: unsupported version 99 → rejected", + headTime: 1000, + modify: func(args *TransactionArgs) { + args.Version = uint16VersionPtr(99) + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + backend := makeBackend(tt.headTime) + args := baseArgs() + tt.modify(&args) + + err := args.setDefaults(context.Background(), backend) + if tt.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if tt.wantVersion == nil { + // Not a MorphTx — Version should remain nil + if args.Version != nil { + t.Errorf("expected Version to be nil (non-MorphTx), got %d", *args.Version) + } + } else { + if args.Version == nil { + t.Fatalf("expected Version = %d, got nil", *tt.wantVersion) + } + if uint16(*args.Version) != *tt.wantVersion { + t.Errorf("Version: got %d, want %d", *args.Version, *tt.wantVersion) + } + } + }) + } +} + +func uint16Ref(v uint8) *uint16 { + u := uint16(v) + return &u +} + +// --- marshalReceipt field-presence tests --- + +// mockReceiptBackend implements just ChainConfig() for marshalReceipt tests. +// IsCurie returns false so marshalReceipt takes the simpler gasPrice path +// and does not call HeaderByHash. +type mockReceiptBackend struct { + Backend // embed — unimplemented methods panic if called +} + +func (m *mockReceiptBackend) ChainConfig() *params.ChainConfig { + return ¶ms.ChainConfig{ + ChainID: big.NewInt(1), + // CurieBlock is nil → IsCurie returns false + } +} + +// signTx is a helper that signs a transaction with the given key and signer. +func signTx(t *testing.T, key *ecdsa.PrivateKey, signer types.Signer, inner types.TxData) *types.Transaction { + t.Helper() + tx, err := types.SignNewTx(key, signer, inner) + if err != nil { + t.Fatalf("failed to sign tx: %v", err) + } + return tx +} + +// TestMarshalReceipt_FieldPresence verifies that all MorphTx-specific fields +// are always present in marshalled receipts regardless of tx type: +// - l1Fee, feeRate, tokenScale, feeTokenID, feeLimit, version, reference, memo +// are unconditionally included (values may be nil/zero for non-MorphTx). +func TestMarshalReceipt_FieldPresence(t *testing.T) { + key, _ := crypto.GenerateKey() + testAddr := common.HexToAddress("0x1234567890abcdef1234567890abcdef12345678") + morphSigner := types.NewEmeraldSigner(big.NewInt(1)) + londonSigner := types.NewLondonSigner(big.NewInt(1)) + + ref := common.HexToReference("0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + memo := []byte("test memo") + feeTokenID := uint16(1) + feeRate := big.NewInt(500) + tokenScale := big.NewInt(1000) + + tests := []struct { + name string + tx *types.Transaction + // receipt fields to populate + receiptFeeTokenID *uint16 + receiptFeeRate *big.Int + receiptTokenScale *big.Int + receiptFeeLimit *big.Int + receiptVersion uint8 + receiptReference *common.Reference + receiptMemo *[]byte + }{ + { + name: "DynamicFeeTx (non-MorphTx)", + tx: signTx(t, key, londonSigner, &types.DynamicFeeTx{ + ChainID: big.NewInt(1), + Nonce: 0, + GasTipCap: big.NewInt(1), + GasFeeCap: big.NewInt(10), + Gas: 21000, + To: &testAddr, + Value: big.NewInt(1), + }), + }, + { + name: "MorphTx V0", + tx: signTx(t, key, morphSigner, &types.MorphTx{ + ChainID: big.NewInt(1), + Nonce: 1, + GasTipCap: big.NewInt(1), + GasFeeCap: big.NewInt(10), + Gas: 21000, + To: &testAddr, + Value: big.NewInt(1), + FeeTokenID: 1, + FeeLimit: big.NewInt(1000), + Version: types.MorphTxVersion0, + }), + receiptFeeTokenID: &feeTokenID, + receiptFeeRate: feeRate, + receiptTokenScale: tokenScale, + receiptFeeLimit: big.NewInt(1000), + receiptVersion: types.MorphTxVersion0, + }, + { + name: "MorphTx V1", + tx: signTx(t, key, morphSigner, &types.MorphTx{ + ChainID: big.NewInt(1), + Nonce: 2, + GasTipCap: big.NewInt(1), + GasFeeCap: big.NewInt(10), + Gas: 21000, + To: &testAddr, + Value: big.NewInt(1), + FeeTokenID: 0, + FeeLimit: big.NewInt(0), + Version: types.MorphTxVersion1, + Reference: &ref, + Memo: &memo, + }), + receiptFeeRate: feeRate, + receiptTokenScale: tokenScale, + receiptFeeLimit: big.NewInt(0), + receiptVersion: types.MorphTxVersion1, + receiptReference: &ref, + receiptMemo: &memo, + }, + } + + backend := &mockReceiptBackend{} + blockHash := common.HexToHash("0xdeadbeef") + bigblock := big.NewInt(1) + + // All MorphTx-related fields should always be present in the result map + allMorphFields := []string{"l1Fee", "feeRate", "tokenScale", "feeTokenID", "feeLimit", "version", "reference", "memo"} + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + receipt := &types.Receipt{ + Status: types.ReceiptStatusSuccessful, + GasUsed: 21000, + FeeTokenID: tt.receiptFeeTokenID, + FeeRate: tt.receiptFeeRate, + TokenScale: tt.receiptTokenScale, + FeeLimit: tt.receiptFeeLimit, + Version: tt.receiptVersion, + Reference: tt.receiptReference, + Memo: tt.receiptMemo, + } + + signer := types.NewEmeraldSigner(big.NewInt(1)) + fields, err := marshalReceipt(context.Background(), backend, receipt, bigblock, blockHash, 1, signer, tt.tx, 0) + if err != nil { + t.Fatalf("marshalReceipt error: %v", err) + } + + // All MorphTx fields should always be present regardless of tx type + for _, field := range allMorphFields { + if _, exists := fields[field]; !exists { + t.Errorf("expected field %q to always be present for %s, but it was absent", field, tt.name) + } + } + }) + } +} diff --git a/internal/ethapi/backend.go b/internal/ethapi/backend.go index 6b0f43a83..2a38fb8a1 100644 --- a/internal/ethapi/backend.go +++ b/internal/ethapi/backend.go @@ -141,6 +141,11 @@ func GetAPIs(apiBackend Backend) []rpc.API { Version: "1.0", Service: NewPrivateAccountAPI(apiBackend, nonceLock), Public: false, + }, { + Namespace: "morph", + Version: "1.0", + Service: NewPublicMorphAPI(apiBackend), + Public: true, }, } } diff --git a/internal/ethapi/transaction_args.go b/internal/ethapi/transaction_args.go index 383829875..30c5cb2c4 100644 --- a/internal/ethapi/transaction_args.go +++ b/internal/ethapi/transaction_args.go @@ -50,9 +50,12 @@ type TransactionArgs struct { Data *hexutil.Bytes `json:"data"` Input *hexutil.Bytes `json:"input"` - // AltFeeTxType - FeeTokenID *hexutil.Uint16 `json:"feeTokenID,omitempty"` - FeeLimit *hexutil.Big `json:"feeLimit,omitempty"` + // MorphTxType + FeeTokenID *hexutil.Uint16 `json:"feeTokenID,omitempty"` + FeeLimit *hexutil.Big `json:"feeLimit,omitempty"` + Version *hexutil.Uint16 `json:"version,omitempty"` + Reference *common.Reference `json:"reference,omitempty"` + Memo *hexutil.Bytes `json:"memo,omitempty"` // Introduced by AccessListTxType transaction. AccessList *types.AccessList `json:"accessList,omitempty"` @@ -81,6 +84,59 @@ func (arg *TransactionArgs) data() []byte { return nil } +// isMorphTxArgs returns true if the transaction args indicate a MorphTx type. +func (args *TransactionArgs) isMorphTxArgs() bool { + // Check if any MorphTx-specific field is set + // Note: Version=0 with FeeTokenID=0 is invalid, but we still want to validate it + return (args.FeeTokenID != nil && *args.FeeTokenID > 0) || + (args.Version != nil) || // Any explicit version setting indicates MorphTx intent + (args.Reference != nil && *args.Reference != (common.Reference{})) || + (args.Memo != nil && len(*args.Memo) > 0) +} + +// validateMorphTxVersion validates the MorphTx version and its associated field requirements. +// If version is explicitly specified, validate that parameters match: +// - Version 0: FeeTokenID must be > 0, Reference and Memo must not be set +// - Version 1: FeeTokenID, Reference, Memo are all optional; +// if FeeTokenID is not set or is 0, FeeLimit must not be set +// +// If version is not explicitly specified, no version-specific validation is needed +// because determineMorphTxVersion will assign the highest version. +func (args *TransactionArgs) validateMorphTxVersion() error { + if !args.isMorphTxArgs() { + return nil + } + + // Only validate when version is explicitly specified + if args.Version == nil { + return nil + } + + version := uint8(*args.Version) + switch version { + case types.MorphTxVersion0: + // Version 0 requires FeeTokenID > 0 + if args.FeeTokenID == nil || *args.FeeTokenID == 0 || + args.Reference != nil && *args.Reference != (common.Reference{}) || + args.Memo != nil && len(*args.Memo) > 0 { + return types.ErrMorphTxV0IllegalExtraParams + } + case types.MorphTxVersion1: + // Version 1: FeeTokenID, Reference, Memo are all optional + // If FeeTokenID is not set or is 0, FeeLimit must not be set + feeTokenID := uint16(0) + if args.FeeTokenID != nil { + feeTokenID = uint16(*args.FeeTokenID) + } + if feeTokenID == 0 && args.FeeLimit != nil && args.FeeLimit.ToInt().Sign() != 0 { + return types.ErrMorphTxV1IllegalExtraParams + } + default: + return types.ErrMorphTxUnsupportedVersion + } + return nil +} + // setDefaults fills in default values for unspecified tx fields. func (args *TransactionArgs) setDefaults(ctx context.Context, b Backend) error { if args.GasPrice != nil && (args.MaxFeePerGas != nil || args.MaxPriorityFeePerGas != nil) { @@ -158,6 +214,42 @@ func (args *TransactionArgs) setDefaults(ctx context.Context, b Backend) error { if args.To == nil && len(args.data()) == 0 { return errors.New(`contract creation without any data provided`) } + // Handle MorphTx version defaults and jade fork validation + if args.isMorphTxArgs() { + isJadeFork := b.ChainConfig().IsJadeFork(head.Time) + if !isJadeFork { + // Reject explicit V1 before jade fork + if args.Version != nil && uint8(*args.Version) == types.MorphTxVersion1 { + return types.ErrMorphTxV1NotYetActive + } + // Reject V1-only fields (reference/memo) before jade fork + if (args.Reference != nil && *args.Reference != (common.Reference{})) || + (args.Memo != nil && len(*args.Memo) > 0) { + return types.ErrMorphTxV1NotYetActive + } + } + // Determine version: explicit > V1 if V1-specific fields present > V0 (backward compatible) + if args.Version == nil { + hasV1Fields := (args.Reference != nil && *args.Reference != (common.Reference{})) || + (args.Memo != nil && len(*args.Memo) > 0) + if hasV1Fields { + v := hexutil.Uint16(types.MorphTxVersion1) + args.Version = &v + } else { + v := hexutil.Uint16(types.MorphTxVersion0) + args.Version = &v + } + } + } + // Validate memo length for MorphTx + if args.Memo != nil && len(*args.Memo) > common.MaxMemoLength { + return errors.New("memo exceeds maximum length of 64 bytes") + } + // Validate MorphTx version and associated field requirements + if err := args.validateMorphTxVersion(); err != nil { + return err + } + // Estimate the gas usage if necessary. if args.Gas == nil { // These fields are immutable during the estimation, safe to @@ -171,6 +263,9 @@ func (args *TransactionArgs) setDefaults(ctx context.Context, b Backend) error { MaxPriorityFeePerGas: args.MaxPriorityFeePerGas, FeeTokenID: args.FeeTokenID, FeeLimit: args.FeeLimit, + Version: args.Version, + Reference: args.Reference, + Memo: args.Memo, Value: args.Value, Data: (*hexutil.Bytes)(&data), AccessList: args.AccessList, @@ -307,16 +402,35 @@ func (args *TransactionArgs) ToMessage(globalGasCap uint64, baseFee *big.Int) (t if args.AccessList != nil { accessList = *args.AccessList } - var feeTokenID uint16 - if args.FeeTokenID != nil { - feeTokenID = uint16(*args.FeeTokenID) - } - feeLimit := new(big.Int) - if args.FeeLimit != nil { - feeLimit = args.FeeLimit.ToInt() + + // Only set MorphTx-specific fields when this is a MorphTx call + var ( + feeTokenID uint16 + feeLimit *big.Int + version uint8 + reference *common.Reference + memo *[]byte + ) + if args.isMorphTxArgs() { + if args.FeeTokenID != nil { + feeTokenID = uint16(*args.FeeTokenID) + } + if args.FeeLimit != nil { + feeLimit = args.FeeLimit.ToInt() + } + // Use version from args (set by setDefaults or explicitly by caller) + if args.Version != nil { + version = uint8(*args.Version) + } + if args.Reference != nil { + reference = args.Reference + } + if args.Memo != nil { + memo = (*[]byte)(args.Memo) + } } - msg := types.NewMessage(addr, args.To, 0, value, gas, gasPrice, gasFeeCap, gasTipCap, feeTokenID, feeLimit, data, accessList, args.AuthorizationList, true) + msg := types.NewMessage(addr, args.To, 0, value, gas, gasPrice, gasFeeCap, gasTipCap, feeTokenID, feeLimit, version, reference, memo, data, accessList, args.AuthorizationList, true) return msg, nil } @@ -326,8 +440,11 @@ func (args *TransactionArgs) toTransaction() *types.Transaction { usedType := types.LegacyTxType switch { // must take precedence over MaxFeePerGas. - case args.FeeTokenID != nil && *args.FeeTokenID > 0: - usedType = types.AltFeeTxType + case (args.FeeTokenID != nil && *args.FeeTokenID > 0) || + (args.Version != nil) || // Any explicit version setting indicates MorphTx intent + (args.Reference != nil && *args.Reference != (common.Reference{})) || + (args.Memo != nil && len(*args.Memo) > 0): + usedType = types.MorphTxType case args.AuthorizationList != nil: usedType = types.SetCodeTxType case args.MaxFeePerGas != nil: @@ -385,20 +502,32 @@ func (args *TransactionArgs) toTransaction() *types.Transaction { AccessList: al, } - case types.AltFeeTxType: + case types.MorphTxType: al := types.AccessList{} if args.AccessList != nil { al = *args.AccessList } - data = &types.AltFeeTx{ + // Use version from args (set by setDefaults or explicitly by caller) + var version uint8 + if args.Version != nil { + version = uint8(*args.Version) + } + var feeTokenID uint16 + if args.FeeTokenID != nil { + feeTokenID = uint16(*args.FeeTokenID) + } + data = &types.MorphTx{ To: args.To, ChainID: (*big.Int)(args.ChainID), Nonce: uint64(*args.Nonce), Gas: uint64(*args.Gas), GasFeeCap: (*big.Int)(args.MaxFeePerGas), GasTipCap: (*big.Int)(args.MaxPriorityFeePerGas), - FeeTokenID: uint16(*args.FeeTokenID), + Version: version, + FeeTokenID: feeTokenID, FeeLimit: (*big.Int)(args.FeeLimit), + Reference: args.Reference, + Memo: (*[]byte)(args.Memo), Value: (*big.Int)(args.Value), Data: args.data(), AccessList: al, diff --git a/les/client.go b/les/client.go index fa0cba19d..f69d54587 100644 --- a/les/client.go +++ b/les/client.go @@ -99,8 +99,8 @@ func New(stack *node.Node, config *ethconfig.Config) (*LightEthereum, error) { if config.OverrideEmeraldTime != nil { overrides.EmeraldTime = config.OverrideEmeraldTime } - if config.OverrideMPTForkTime != nil { - overrides.MPTForkTime = config.OverrideMPTForkTime + if config.OverrideJadeForkTime != nil { + overrides.JadeForkTime = config.OverrideJadeForkTime } chainConfig, genesisHash, genesisErr := core.SetupGenesisBlockWithOverride(chainDb, config.Genesis, &overrides) if _, isCompat := genesisErr.(*params.ConfigCompatError); genesisErr != nil && !isCompat { diff --git a/les/odr_test.go b/les/odr_test.go index f70031418..088cf820d 100644 --- a/les/odr_test.go +++ b/les/odr_test.go @@ -141,7 +141,27 @@ func odrContractCall(ctx context.Context, db ethdb.Database, config *params.Chai from := statedb.GetOrNewStateObject(bankAddr) from.SetBalance(math.MaxBig256) - msg := callmsg{types.NewMessage(from.Address(), &testContractAddr, 0, new(big.Int), 100000, big.NewInt(params.InitialBaseFee), big.NewInt(params.InitialBaseFee), new(big.Int), data, nil, nil, true)} + msg := callmsg{ + types.NewMessage( + from.Address(), + &testContractAddr, + 0, + new(big.Int), + 100000, + big.NewInt(params.InitialBaseFee), + big.NewInt(params.InitialBaseFee), + new(big.Int), + 0, + new(big.Int), + 0, + nil, + nil, + data, + nil, + nil, + true, + ), + } context := core.NewEVMBlockContext(header, bc, config, nil) txContext := core.NewEVMTxContext(msg) @@ -158,7 +178,26 @@ func odrContractCall(ctx context.Context, db ethdb.Database, config *params.Chai header := lc.GetHeaderByHash(bhash) state := light.NewState(ctx, header, lc.Odr()) state.SetBalance(bankAddr, math.MaxBig256, tracing.BalanceChangeUnspecified) - msg := callmsg{types.NewMessage(bankAddr, &testContractAddr, 0, new(big.Int), 100000, big.NewInt(params.InitialBaseFee), big.NewInt(params.InitialBaseFee), new(big.Int), data, nil, nil, true)} + msg := callmsg{ + types.NewMessage( + bankAddr, + &testContractAddr, + 0, new(big.Int), + 100000, + big.NewInt(params.InitialBaseFee), + big.NewInt(params.InitialBaseFee), + new(big.Int), + 0, + new(big.Int), + 0, + nil, + nil, + data, + nil, + nil, + true, + ), + } context := core.NewEVMBlockContext(header, lc, config, nil) txContext := core.NewEVMTxContext(msg) vmenv := vm.NewEVM(context, txContext, state, config, vm.Config{NoBaseFee: true}) diff --git a/light/odr_test.go b/light/odr_test.go index 3d5dc091c..4e55381f9 100644 --- a/light/odr_test.go +++ b/light/odr_test.go @@ -199,7 +199,27 @@ func odrContractCall(ctx context.Context, db ethdb.Database, bc *core.BlockChain // Perform read-only call. st.SetBalance(testBankAddress, math.MaxBig256, tracing.BalanceChangeUnspecified) - msg := callmsg{types.NewMessage(testBankAddress, &testContractAddr, 0, new(big.Int), 1000000, big.NewInt(params.InitialBaseFee), big.NewInt(params.InitialBaseFee), new(big.Int), 0, nil, data, nil, nil, true)} + msg := callmsg{ + types.NewMessage( + testBankAddress, + &testContractAddr, + 0, + new(big.Int), + 1000000, + big.NewInt(params.InitialBaseFee), + big.NewInt(params.InitialBaseFee), + new(big.Int), + 0, + nil, + 0, + nil, + nil, + data, + nil, + nil, + true, + ), + } txContext := core.NewEVMTxContext(msg) context := core.NewEVMBlockContext(header, chain, config, nil) vmenv := vm.NewEVM(context, txContext, st, config, vm.Config{NoBaseFee: true}) diff --git a/light/txpool.go b/light/txpool.go index 09a1e0816..0756c2338 100644 --- a/light/txpool.go +++ b/light/txpool.go @@ -412,10 +412,15 @@ func (pool *TxPool) validateTx(ctx context.Context, tx *types.Transaction) error if tx.Value().Sign() < 0 { return core.ErrNegativeValue } + + if err := tx.ValidateMorphTxVersion(); err != nil { + return err + } + // 1. Check balance >= transaction cost (V + GP * GL) to maintain compatibility with the logic without considering L1 data fee. // Transactor should have enough funds to cover the costs // cost == V + GP * GL - if tx.IsAltFeeTx() { + if tx.IsMorphTxWithAltFee() { active, err := fees.IsTokenActive(currentState, tx.FeeTokenID()) if err != nil { return fmt.Errorf("get token status failed %v", err) @@ -455,7 +460,7 @@ func (pool *TxPool) validateTx(ctx context.Context, tx *types.Transaction) error } // Transactor should have enough funds to cover the costs // cost == L1 data fee + V + GP * GL - if tx.IsAltFeeTx() { + if tx.IsMorphTxWithAltFee() { if b := currentState.GetBalance(from); b.Cmp(tx.Value()) < 0 { return errors.New("invalid transaction: insufficient funds for value") } diff --git a/params/config.go b/params/config.go index 96092c705..dd6bee1df 100644 --- a/params/config.go +++ b/params/config.go @@ -589,7 +589,7 @@ type ChainConfig struct { Morph203Time *uint64 `json:"morph203Time,omitempty"` // Morph203Time switch time (nil = no fork, 0 = already on morph203) ViridianTime *uint64 `json:"viridianTime,omitempty"` // ViridianTime switch time (nil = no fork, 0 = already on viridian) EmeraldTime *uint64 `json:"emeraldTime,omitempty"` // EmeraldTime switch time (nil = no fork, 0 = already on emerald) - MPTForkTime *uint64 `json:"mptForkTime,omitempty"` // MPTForkTime switch time (nil = no fork, blocks use zkTrie format) + JadeForkTime *uint64 `json:"jadeForkTime,omitempty"` // JadeForkTime switch time (nil = no fork, blocks use zkTrie format) // TerminalTotalDifficulty is the amount of total difficulty reached by // the network that triggers the consensus upgrade. @@ -701,7 +701,8 @@ func (c *ChainConfig) String() string { default: engine = "unknown" } - return fmt.Sprintf("{ChainID: %v Homestead: %v DAO: %v DAOSupport: %v EIP150: %v EIP155: %v EIP158: %v Byzantium: %v Constantinople: %v Petersburg: %v Istanbul: %v, Muir Glacier: %v, Berlin: %v, London: %v, Arrow Glacier: %v, Archimedes: %v, Shanghai: %v, Bernoulli: %v, Curie: %v, Morph203: %v, Viridian: %v, Emerald: %v, MPTFork: %v, Engine: %v, Morph config: %v}", + return fmt.Sprintf( + "{ChainID: %v Homestead: %v DAO: %v DAOSupport: %v EIP150: %v EIP155: %v EIP158: %v Byzantium: %v Constantinople: %v Petersburg: %v Istanbul: %v, Muir Glacier: %v, Berlin: %v, London: %v, Arrow Glacier: %v, Archimedes: %v, Shanghai: %v, Bernoulli: %v, Curie: %v, Morph203: %v, Viridian: %v, Emerald: %v, JadeFork: %v, Engine: %v, Morph config: %v}", c.ChainID, c.HomesteadBlock, c.DAOForkBlock, @@ -724,7 +725,7 @@ func (c *ChainConfig) String() string { c.Morph203Time, c.ViridianTime, c.EmeraldTime, - c.MPTForkTime, + c.JadeForkTime, engine, c.Morph, ) @@ -830,10 +831,10 @@ func (c *ChainConfig) IsEmerald(num *big.Int, time uint64) bool { return isTimestampForked(c.EmeraldTime, time) } -// IsMPTFork returns whether the given time is at or after the MPT fork time. +// IsJadeFork returns whether the given time is at or after the Jade fork time. // After this fork, blocks use MPT (Merkle Patricia Trie) format instead of zkTrie. -func (c *ChainConfig) IsMPTFork(time uint64) bool { - return isTimestampForked(c.MPTForkTime, time) +func (c *ChainConfig) IsJadeFork(time uint64) bool { + return isTimestampForked(c.JadeForkTime, time) } // IsTerminalPoWBlock returns whether the given block is the last block of PoW stage. @@ -900,7 +901,7 @@ func (c *ChainConfig) CheckConfigForkOrder() error { {name: "morph203Time", timestamp: c.Morph203Time, optional: true}, {name: "viridianTime", timestamp: c.ViridianTime, optional: true}, {name: "emeraldTime", timestamp: c.EmeraldTime, optional: true}, - {name: "mptForkTime", timestamp: c.MPTForkTime, optional: true}, + {name: "jadeForkTime", timestamp: c.JadeForkTime, optional: true}, } { if lastFork.name != "" { switch { @@ -1010,8 +1011,8 @@ func (c *ChainConfig) checkCompatible(newcfg *ChainConfig, head *big.Int, headTi if isForkTimestampIncompatible(c.EmeraldTime, newcfg.EmeraldTime, headTimestamp) { return newTimestampCompatError("EmeraldTime fork timestamp", c.EmeraldTime, newcfg.EmeraldTime) } - if isForkTimestampIncompatible(c.MPTForkTime, newcfg.MPTForkTime, headTimestamp) { - return newTimestampCompatError("MPTForkTime fork timestamp", c.MPTForkTime, newcfg.MPTForkTime) + if isForkTimestampIncompatible(c.JadeForkTime, newcfg.JadeForkTime, headTimestamp) { + return newTimestampCompatError("JadeForkTime fork timestamp", c.JadeForkTime, newcfg.JadeForkTime) } return nil } @@ -1155,11 +1156,11 @@ func (err *ConfigCompatError) Error() string { // Rules is a one time interface meaning that it shouldn't be used in between transition // phases. type Rules struct { - ChainID *big.Int - IsHomestead, IsEIP150, IsEIP155, IsEIP158 bool - IsByzantium, IsConstantinople, IsPetersburg, IsIstanbul bool - IsBerlin, IsLondon, IsArchimedes, IsShanghai bool - IsBernoulli, IsCurie, IsMorph203, IsViridian, IsEmerald, IsMPTFork bool + ChainID *big.Int + IsHomestead, IsEIP150, IsEIP155, IsEIP158 bool + IsByzantium, IsConstantinople, IsPetersburg, IsIstanbul bool + IsBerlin, IsLondon, IsArchimedes, IsShanghai, IsBernoulli bool + IsCurie, IsMorph203, IsViridian, IsEmerald, IsJadeFork bool } // Rules ensures c's ChainID is not nil. @@ -1187,7 +1188,7 @@ func (c *ChainConfig) Rules(num *big.Int, time uint64) Rules { IsMorph203: c.IsMorph203(time), IsViridian: c.IsViridian(num, time), IsEmerald: c.IsEmerald(num, time), - IsMPTFork: c.IsMPTFork(time), + IsJadeFork: c.IsJadeFork(time), } } diff --git a/rollup/fees/rollup_fee.go b/rollup/fees/rollup_fee.go index 456776fe5..7d8dadd37 100644 --- a/rollup/fees/rollup_fee.go +++ b/rollup/fees/rollup_fee.go @@ -37,6 +37,9 @@ type Message interface { IsL1MessageTx() bool FeeTokenID() uint16 FeeLimit() *big.Int + Version() uint8 + Reference() *common.Reference + Memo() *[]byte } // StateDB represents the StateDB interface @@ -94,8 +97,11 @@ func asUnsignedTx(msg Message, baseFee, chainID *big.Int) *types.Transaction { return asUnsignedAccessListTx(msg, chainID) } - if msg.FeeTokenID() != 0 { - return asUnsignedAltFeeTx(msg, chainID) + if msg.FeeTokenID() != 0 || + msg.Version() != types.MorphTxVersion0 || + (msg.Reference() != nil && *msg.Reference() != (common.Reference{})) || + (msg.Memo() != nil && len(*msg.Memo()) > 0) { + return asUnsignedMorphTx(msg, chainID) } return asUnsignedDynamicTx(msg, chainID) @@ -139,8 +145,8 @@ func asUnsignedDynamicTx(msg Message, chainID *big.Int) *types.Transaction { }) } -func asUnsignedAltFeeTx(msg Message, chainID *big.Int) *types.Transaction { - return types.NewTx(&types.AltFeeTx{ +func asUnsignedMorphTx(msg Message, chainID *big.Int) *types.Transaction { + return types.NewTx(&types.MorphTx{ Nonce: msg.Nonce(), To: msg.To(), Value: msg.Value(), @@ -149,6 +155,9 @@ func asUnsignedAltFeeTx(msg Message, chainID *big.Int) *types.Transaction { GasTipCap: msg.GasTipCap(), FeeTokenID: msg.FeeTokenID(), FeeLimit: msg.FeeLimit(), + Version: msg.Version(), + Reference: msg.Reference(), + Memo: msg.Memo(), Data: msg.Data(), AccessList: msg.AccessList(), ChainID: chainID, diff --git a/rollup/tracing/tracing.go b/rollup/tracing/tracing.go index 8610d1376..41bd52374 100644 --- a/rollup/tracing/tracing.go +++ b/rollup/tracing/tracing.go @@ -355,9 +355,9 @@ func (env *TraceEnv) getTxResult(statedb *state.StateDB, index int, block *types } } - // For AltFeeTx, manually collect token contract bytecode + // For MorphTx, manually collect token contract bytecode // since direct storage slot operations don't trigger EVM execution - if tx.Type() == types.AltFeeTxType && tx.FeeTokenID() != 0 { + if tx.Type() == types.MorphTxType && tx.FeeTokenID() != 0 { tokenInfo, err := fees.GetTokenInfo(statedb, tx.FeeTokenID()) if err == nil && tokenInfo.TokenAddress != (common.Address{}) { collectBytecode := func(addr common.Address) { @@ -524,6 +524,9 @@ func (env *TraceEnv) getTxResult(statedb *state.StateDB, index int, block *types L1DataFee: (*hexutil.Big)(receipt.L1Fee), FeeTokenID: receipt.FeeTokenID, FeeLimit: (*hexutil.Big)(receipt.FeeLimit), + Version: &receipt.Version, + Reference: receipt.Reference, + Memo: (*hexutil.Bytes)(receipt.Memo), FeeRate: (*hexutil.Big)(receipt.FeeRate), TokenScale: (*hexutil.Big)(receipt.TokenScale), Gas: receipt.GasUsed, diff --git a/rpc/types.go b/rpc/types.go index 73570c4b0..61174d9f3 100644 --- a/rpc/types.go +++ b/rpc/types.go @@ -264,3 +264,18 @@ func (dh *DecimalOrHex) UnmarshalJSON(data []byte) error { *dh = DecimalOrHex(value) return nil } + +// ReferenceTransactionResult represents a simplified transaction result for reference queries. +type ReferenceTransactionResult struct { + TransactionHash common.Hash `json:"transactionHash"` + BlockNumber hexutil.Uint64 `json:"blockNumber"` + BlockTimestamp hexutil.Uint64 `json:"blockTimestamp"` + TransactionIndex hexutil.Uint64 `json:"transactionIndex"` +} + +// ReferenceQueryArgs represents the arguments for querying transactions by reference. +type ReferenceQueryArgs struct { + Reference common.Reference `json:"reference"` + Offset *hexutil.Uint64 `json:"offset,omitempty"` + Limit *hexutil.Uint64 `json:"limit,omitempty"` +} diff --git a/signer/core/apitypes/types.go b/signer/core/apitypes/types.go index cdc9121f5..a6ba9b0c3 100644 --- a/signer/core/apitypes/types.go +++ b/signer/core/apitypes/types.go @@ -89,6 +89,9 @@ type SendTxArgs struct { ChainID *hexutil.Big `json:"chainId,omitempty"` FeeTokenID *hexutil.Uint16 `json:"feeTokenID,omitempty"` FeeLimit *hexutil.Big `json:"feeLimit,omitempty"` + Version *hexutil.Uint64 `json:"version,omitempty"` + Reference *common.Reference `json:"reference,omitempty"` + Memo *hexutil.Bytes `json:"memo,omitempty"` } func (args SendTxArgs) String() string { @@ -118,20 +121,38 @@ func (args *SendTxArgs) ToTransaction() *types.Transaction { var data types.TxData switch { // must take precedence over MaxFeePerGas. - case args.FeeTokenID != nil && *args.FeeTokenID > 0: + case (args.FeeTokenID != nil && *args.FeeTokenID > 0) || + (args.Version != nil) || // Any explicit version setting indicates MorphTx intent + (args.Reference != nil && *args.Reference != (common.Reference{})) || + (args.Memo != nil && len(*args.Memo) > 0): al := types.AccessList{} if args.AccessList != nil { al = *args.AccessList } - data = &types.AltFeeTx{ + // Determine version: explicit > V1 if V1-specific fields present > V0 (backward compatible) + version := uint8(types.MorphTxVersion0) + if args.Version != nil { + version = uint8(*args.Version) + } else if (args.Reference != nil && *args.Reference != (common.Reference{})) || + (args.Memo != nil && len(*args.Memo) > 0) { + version = uint8(types.MorphTxVersion1) + } + var feeTokenID uint16 + if args.FeeTokenID != nil { + feeTokenID = uint16(*args.FeeTokenID) + } + data = &types.MorphTx{ To: to, ChainID: (*big.Int)(args.ChainID), Nonce: uint64(args.Nonce), Gas: uint64(args.Gas), GasFeeCap: (*big.Int)(args.MaxFeePerGas), GasTipCap: (*big.Int)(args.MaxPriorityFeePerGas), - FeeTokenID: uint16(*args.FeeTokenID), + FeeTokenID: feeTokenID, FeeLimit: (*big.Int)(args.FeeLimit), + Version: version, + Reference: args.Reference, + Memo: (*[]byte)(args.Memo), Value: (*big.Int)(&args.Value), Data: input, AccessList: al, diff --git a/signer/core/apitypes/types_test.go b/signer/core/apitypes/types_test.go new file mode 100644 index 000000000..f1e1781e8 --- /dev/null +++ b/signer/core/apitypes/types_test.go @@ -0,0 +1,219 @@ +package apitypes + +import ( + "testing" + + "github.com/morph-l2/go-ethereum/common" + "github.com/morph-l2/go-ethereum/common/hexutil" + "github.com/morph-l2/go-ethereum/core/types" +) + +func uint16Ptr(v uint16) *hexutil.Uint16 { + h := hexutil.Uint16(v) + return &h +} + +func uint64VersionPtr(v uint64) *hexutil.Uint64 { + h := hexutil.Uint64(v) + return &h +} + +func refPtr(r common.Reference) *common.Reference { + return &r +} + +func memoPtr(b []byte) *hexutil.Bytes { + h := hexutil.Bytes(b) + return &h +} + +// TestSendTxArgs_ToTransaction_VersionHeuristic tests the heuristic version defaulting logic +// in SendTxArgs.ToTransaction(): +// - Explicit Version → use as-is +// - No Version + Reference or Memo present → V1 +// - No Version + no V1 fields → V0 (backward compatible) +func TestSendTxArgs_ToTransaction_VersionHeuristic(t *testing.T) { + from := common.NewMixedcaseAddress(common.HexToAddress("0x1234")) + to := common.NewMixedcaseAddress(common.HexToAddress("0x5678")) + ref := common.HexToReference("0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + emptyRef := common.Reference{} + maxFee := (*hexutil.Big)(common.Big1) + tip := (*hexutil.Big)(common.Big1) + + tests := []struct { + name string + args SendTxArgs + expectType uint8 + expectVersion uint8 + expectIsMorphTx bool + }{ + { + name: "FeeTokenID only, no Version → V0", + args: SendTxArgs{ + From: from, + To: &to, + MaxFeePerGas: maxFee, + MaxPriorityFeePerGas: tip, + FeeTokenID: uint16Ptr(1), + }, + expectType: types.MorphTxType, + expectVersion: types.MorphTxVersion0, + expectIsMorphTx: true, + }, + { + name: "FeeTokenID + Reference → V1", + args: SendTxArgs{ + From: from, + To: &to, + MaxFeePerGas: maxFee, + MaxPriorityFeePerGas: tip, + FeeTokenID: uint16Ptr(1), + Reference: refPtr(ref), + }, + expectType: types.MorphTxType, + expectVersion: types.MorphTxVersion1, + expectIsMorphTx: true, + }, + { + name: "FeeTokenID + Memo → V1", + args: SendTxArgs{ + From: from, + To: &to, + MaxFeePerGas: maxFee, + MaxPriorityFeePerGas: tip, + FeeTokenID: uint16Ptr(1), + Memo: memoPtr([]byte("hello")), + }, + expectType: types.MorphTxType, + expectVersion: types.MorphTxVersion1, + expectIsMorphTx: true, + }, + { + name: "Reference only (no FeeTokenID) → V1", + args: SendTxArgs{ + From: from, + To: &to, + MaxFeePerGas: maxFee, + MaxPriorityFeePerGas: tip, + Reference: refPtr(ref), + }, + expectType: types.MorphTxType, + expectVersion: types.MorphTxVersion1, + expectIsMorphTx: true, + }, + { + name: "Memo only (no FeeTokenID) → V1", + args: SendTxArgs{ + From: from, + To: &to, + MaxFeePerGas: maxFee, + MaxPriorityFeePerGas: tip, + Memo: memoPtr([]byte("test")), + }, + expectType: types.MorphTxType, + expectVersion: types.MorphTxVersion1, + expectIsMorphTx: true, + }, + { + name: "Empty Reference (all zeros) + FeeTokenID → V0", + args: SendTxArgs{ + From: from, + To: &to, + MaxFeePerGas: maxFee, + MaxPriorityFeePerGas: tip, + FeeTokenID: uint16Ptr(1), + Reference: refPtr(emptyRef), + }, + expectType: types.MorphTxType, + expectVersion: types.MorphTxVersion0, + expectIsMorphTx: true, + }, + { + name: "Empty Memo (len=0) + FeeTokenID → V0", + args: SendTxArgs{ + From: from, + To: &to, + MaxFeePerGas: maxFee, + MaxPriorityFeePerGas: tip, + FeeTokenID: uint16Ptr(1), + Memo: memoPtr([]byte{}), + }, + expectType: types.MorphTxType, + expectVersion: types.MorphTxVersion0, + expectIsMorphTx: true, + }, + { + name: "Explicit Version=0 with Reference → V0 (explicit wins)", + args: SendTxArgs{ + From: from, + To: &to, + MaxFeePerGas: maxFee, + MaxPriorityFeePerGas: tip, + FeeTokenID: uint16Ptr(1), + Version: uint64VersionPtr(uint64(types.MorphTxVersion0)), + Reference: refPtr(ref), + }, + expectType: types.MorphTxType, + expectVersion: types.MorphTxVersion0, + expectIsMorphTx: true, + }, + { + name: "Explicit Version=1 without V1 fields → V1 (explicit wins)", + args: SendTxArgs{ + From: from, + To: &to, + MaxFeePerGas: maxFee, + MaxPriorityFeePerGas: tip, + Version: uint64VersionPtr(uint64(types.MorphTxVersion1)), + }, + expectType: types.MorphTxType, + expectVersion: types.MorphTxVersion1, + expectIsMorphTx: true, + }, + { + name: "Reference + Memo → V1", + args: SendTxArgs{ + From: from, + To: &to, + MaxFeePerGas: maxFee, + MaxPriorityFeePerGas: tip, + Reference: refPtr(ref), + Memo: memoPtr([]byte("memo")), + }, + expectType: types.MorphTxType, + expectVersion: types.MorphTxVersion1, + expectIsMorphTx: true, + }, + { + name: "MaxFeePerGas only → DynamicFeeTx (not MorphTx)", + args: SendTxArgs{ + From: from, + To: &to, + MaxFeePerGas: maxFee, + MaxPriorityFeePerGas: tip, + }, + expectType: types.DynamicFeeTxType, + expectVersion: 0, + expectIsMorphTx: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tx := tt.args.ToTransaction() + + if tx.Type() != tt.expectType { + t.Errorf("tx type: got %d, want %d", tx.Type(), tt.expectType) + } + + if tt.expectIsMorphTx { + if !tx.IsMorphTx() { + t.Fatal("expected IsMorphTx() = true") + } + if tx.Version() != tt.expectVersion { + t.Errorf("version: got %d, want %d", tx.Version(), tt.expectVersion) + } + } + }) + } +} diff --git a/tests/state_test_util.go b/tests/state_test_util.go index 75cd62b24..c480adb47 100644 --- a/tests/state_test_util.go +++ b/tests/state_test_util.go @@ -113,8 +113,11 @@ type stTransaction struct { Value []string `json:"value"` PrivateKey []byte `json:"secretKey"` - FeeTokenID uint16 `json:"feeTokenID,omitempty"` - FeeLimit *big.Int `json:"feeLimit,omitempty"` + FeeTokenID uint16 `json:"feeTokenID,omitempty"` + FeeLimit *big.Int `json:"feeLimit,omitempty"` + Version byte `json:"version,omitempty"` + Reference *common.Reference `json:"reference,omitempty"` + Memo *[]byte `json:"memo,omitempty"` } type stTransactionMarshaling struct { @@ -374,9 +377,12 @@ func (tx *stTransaction) toMessage(ps stPostState, baseFee *big.Int) (core.Messa return nil, fmt.Errorf("no gas price provided") } - msg := types.NewMessage(from, to, tx.Nonce, value, gasLimit, gasPrice, - tx.MaxFeePerGas, tx.MaxPriorityFeePerGas, tx.FeeTokenID, tx.FeeLimit, - data, accessList, nil, false) + msg := types.NewMessage( + from, to, tx.Nonce, value, gasLimit, gasPrice, + tx.MaxFeePerGas, tx.MaxPriorityFeePerGas, + tx.FeeTokenID, tx.FeeLimit, tx.Version, tx.Reference, tx.Memo, + data, accessList, nil, false, + ) return msg, nil }