Skip to main content

MorphTx Developer Guide

Abstract

Morph is designed as a payment settlement layer — a high-performance L2 blockchain purpose-built for real-world payment and commerce use cases. To bridge the gap between on-chain transactions and off-chain payment systems, Morph introduces MorphTx (0x7F), a custom EIP-2718 typed transaction that extends the standard EIP-1559 DynamicFeeTx with two key capabilities. MorphTx consolidates and extends the AltFeeTx transaction type introduced in the Emerald upgrade, evolving it into a unified transaction primitive with broader functionality:

Alternative Fee Transaction enables users to pay gas fees using on-chain registered ERC-20 tokens (e.g., USDT, USDC) instead of ETH. This removes a critical barrier for payment adoption — merchants and end users no longer need to hold native ETH to interact with the chain. Fee conversion happens at the protocol level using an on-chain oracle, eliminating any dependency on Paymaster contracts or external bundlers.

Reference Key introduces a 32-byte merchant-controlled index field embedded natively in the transaction structure. This field is designed to meet three requirements for production payment integration:

  1. Indexed query support — a dedicated on-chain index keyed by Reference allows off-chain systems to efficiently retrieve all transactions associated with a given order, batch, or session, without full chain scanning.
  2. Zero performance impact — the index is maintained asynchronously during block insertion and uses a prefix-ordered key layout, adding negligible overhead to the critical execution path.
  3. Merchant-friendly generation — a Reference can be any deterministic 32-byte value: a keccak256 hash of an order ID, a UUID, or a composite business key. No smart contract interaction is required to register or use a Reference.

Together, these features make MorphTx the foundational transaction primitive for payment settlement on Morph: token-denominated fees reduce friction for merchants and users, while Reference-based indexing provides the traceability and reconciliation primitives that payment systems require.


1. New Transaction Type

MorphTx is a custom EIP-2718 typed transaction for Morph L2, with type ID 0x7F (127). It extends the standard DynamicFeeTx (EIP-1559) with the following capabilities:

FeatureDescription
Alt Fee (Alternative Token Gas Fee)Pay gas fees with on-chain registered ERC-20 tokens instead of ETH
Reference32-byte transaction reference key for on-chain indexing and querying
MemoUp to 64-byte transaction note

1.1 Version System

MorphTx uses a multi-version encoding format for backward compatibility:

VersionConstantStatusDescription
V0MorphTxVersion0 = 0Available after Emerald forkLegacy format, compatible with original AltFeeTx. Supports Alt Fee only — no Version/Reference/Memo fields. FeeTokenID must be > 0
V1MorphTxVersion1 = 1Available after Jade forkNew format. Supports Alt Fee + Reference + Memo. All Morph extension fields are optional. FeeTokenID can be 0 (pay with ETH)

1.2 Key Characteristics

  • Gas pricing model: Fully compatible with EIP-1559 — uses maxFeePerGas + maxPriorityFeePerGas
  • Signing algorithm: Standard secp256k1 ECDSA, V value is {0, 1} (same as DynamicFeeTx)
  • Chain ID binding: ChainID is included in the signing hash to prevent cross-chain replay

2. Data Structures

2.1 Core Struct — MorphTx

Source: core/types/morph_tx.go

type MorphTx struct {
// ===== Same base fields as DynamicFeeTx =====
ChainID *big.Int
Nonce uint64
GasTipCap *big.Int // maxPriorityFeePerGas
GasFeeCap *big.Int // maxFeePerGas
Gas uint64
To *common.Address // nil = contract creation
Value *big.Int
Data []byte
AccessList AccessList

// ===== Morph extension fields =====
Version uint8 // Version (0=V0, 1=V1)
FeeTokenID uint16 // ERC-20 token ID (0=ETH)
FeeLimit *big.Int // Token fee cap
Reference *common.Reference // 32-byte reference key (V1 only)
Memo *[]byte // Note (V1 only, ≤ 64 bytes)

// ===== Signature values =====
V *big.Int
R *big.Int
S *big.Int
}

2.2 Dependent Types

TypeDefined inDescription
common.Referencecommon/types.go[32]byte fixed-length array, supports Hex/JSON/GraphQL serialization
common.ReferenceLengthcommon/types.goConstant 32
common.MaxMemoLengthcommon/types.goConstant 64
AccessListcore/types/access_list_tx.go[]AccessTuple, EIP-2930 access list

2.3 Version Validation Rules

Source: core/types/transaction.goValidateMorphTxVersion()

VersionFeeTokenIDFeeLimitReferenceMemoValid?
V0Must be > 0OptionalMust be nil/emptyMust be nil/empty
V0= 0---ErrMorphTxV0IllegalExtraParams
V0> 0-Non-empty-ErrMorphTxV0IllegalExtraParams
V1AnyAnyAny≤ 64B
V1= 0> 0--ErrMorphTxV1IllegalExtraParams
V1---> 64BErrMemoTooLong
Other----ErrMorphTxUnsupportedVersion

Core constraints:

  • V0 is designed specifically for Alt Fee — FeeTokenID must be non-zero
  • V1 is general-purpose — all extension fields are optional. But if FeeTokenID == 0 (using ETH), FeeLimit must be zero/nil (no need to cap ETH fees)
  • Memo max 64 bytes applies to all versions

2.4 Error Types

ErrorMeaning
ErrMorphTxV0IllegalExtraParamsV0 params invalid (FeeTokenID=0, or Reference/Memo set)
ErrMorphTxV1IllegalExtraParamsV1 params invalid (FeeTokenID=0 but FeeLimit>0)
ErrMorphTxUnsupportedVersionUnsupported version number
ErrMorphTxV1NotYetActiveV1 tx submitted before Jade fork activation
ErrMemoTooLongMemo exceeds 64 bytes

3. Fork Timeline & Compatibility

MorphTx requires two forks to fully enable:

Timeline ──────────────────────────────────────────────────────────►

│ │ │
│ Emerald │ Jade │
│ Fork │ Fork │
▼ ▼ │
────┼───────────────┼────────────────┼──
│ │ │
│ MorphTxType │ MorphTx V1 │
│ signer │ enabled │
│ enabled │ (V0 + V1) │
│ (V0 only) │ │
│ │ │

3.1 Emerald Fork

Source: core/types/transaction_signing.gonewModernSigner() Config: params/config.goEmeraldTime

After Emerald fork activation:

  • MorphTxType (0x7F) is registered in modernSigner's supported type list
  • MakeSigner() returns a signer that accepts MorphTx
  • Only V0 is available (V1 is rejected by tx pool and block validator)

Before Emerald fork:

  • MorphTx is rejected at the tx pool level (ErrTxTypeNotSupported)
  • EIP-1559 must already be active (MorphTx depends on the eip1559 flag)

3.2 Jade Fork

Source: core/tx_pool.go, core/block_validator.go Config: params/config.goJadeForkTime

After Jade fork activation:

  • V1 transactions are accepted by the tx pool
  • Block validation allows V1 transactions
  • Reference index system becomes active

Before Jade fork:

  • Tx pool rejects V1: !pool.jade && tx.IsMorphTx() && tx.Version() == MorphTxVersion1
  • Block validation rejects V1: !isJadeFork && tx.IsMorphTx() && tx.Version() == MorphTxVersion1
  • RPC setDefaults rejects V1 fields (Reference/Memo) and explicit V1 version

On Jade fork rollback (reorg):

  • Tx pool actively removes all V1 transactions (removeMorphTxV1())

3.3 Fork Configuration Example (Mainnet)

{
"emeraldTime": 1766988000,
"jadeForkTime": 1774418400
}

4. Encoding/Decoding (Wire Format)

4.1 Full Wire Format

MorphTx follows the EIP-2718 envelope format: type byte + inner payload

┌─────────────────────────────────────────────────────────┐
│ Full wire format (MarshalBinary output) │
├──────┬──────────────────────────────────────────────────┤
│ 0x7F │ inner payload (generated by encode()) │
│txType│ │
├──────┴──────────────────────────────────────────────────┤
│ │
│ V0 inner payload: │
│ ┌─────────────────────────────────────────────┐ │
│ │ RLP([chainID, nonce, gasTipCap, gasFeeCap, │ │
│ │ gas, to, value, data, accessList, │ │
│ │ feeTokenID, feeLimit, v, r, s]) │ │
│ │ First byte >= 0xC0 (RLP list prefix) │ │
│ │ 14 fields total │ │
│ └─────────────────────────────────────────────┘ │
│ │
│ V1 inner payload: │
│ ┌────┬────────────────────────────────────────┐ │
│ │0x01│ RLP([chainID, nonce, gasTipCap, │ │
│ │ver │ gasFeeCap, gas, to, value, data, │ │
│ │ │ accessList, feeTokenID, feeLimit, │ │
│ │ │ reference, memo, v, r, s]) │ │
│ │ │ 16 fields total │ │
│ └────┴────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────┘

4.2 V0 Encoding

Format: 0x7F || RLP([14 fields])

Field order (inside RLP list):
0. chainID (*big.Int)
1. nonce (uint64)
2. gasTipCap (*big.Int)
3. gasFeeCap (*big.Int)
4. gas (uint64)
5. to (*common.Address, nil=contract creation)
6. value (*big.Int)
7. data ([]byte)
8. accessList (AccessList)
9. feeTokenID (uint16)
10. feeLimit (*big.Int)
11. v (*big.Int)
12. r (*big.Int)
13. s (*big.Int)

Constraint: feeTokenID must be non-zero.

4.3 V1 Encoding

Format: 0x7F || 0x01 || RLP([16 fields])

Field order (inside RLP list):
0. chainID (*big.Int)
1. nonce (uint64)
2. gasTipCap (*big.Int)
3. gasFeeCap (*big.Int)
4. gas (uint64)
5. to (*common.Address, nil=contract creation)
6. value (*big.Int)
7. data ([]byte)
8. accessList (AccessList)
9. feeTokenID (uint16)
10. feeLimit (*big.Int)
11. reference ([]byte, length 0 or 32)
12. memo ([]byte, length 0~64)
13. v (*big.Int)
14. r (*big.Int)
15. s (*big.Int)

Key details:

  • The version byte (0x01) is NOT inside the RLP list — it's a prefix byte
  • reference and memo use []byte type in RLP (not [32]byte); empty values encode as RLP empty string (0x80)
  • V1 adds reference and memo compared to V0's 14 fields (16 total)

4.4 Version Detection Algorithm

When decoding inner payload, the first byte determines the version:

firstByte = inner_payload[0]

if firstByte >= 0xC0 or firstByte == 0x00:
→ V0 format (first byte is RLP list prefix)
→ Decode entire payload as RLP list

elif firstByte == 0x01:
→ V1 format (first byte is version)
→ Skip 1 byte, decode remainder as RLP list

else:
→ Unsupported version

Design rationale: V0's RLP list first byte is always >= 0xC0 (RLP list encoding prefix), while V1's first byte is 0x01. They naturally don't overlap in byte space — no extra metadata needed to distinguish them.

4.5 Encoding Paths

Two encoding paths exist, both producing identical byte output:

Path 1: Transaction.MarshalBinary()
└→ Transaction.encodeTyped(buf)
├→ buf.WriteByte(0x7F) // txType prefix
└→ MorphTx.encode(buf) // internal encoding (V0 or V1)

Path 2: rlp.Encode(w, morphTx)
└→ MorphTx.EncodeRLP(w) // custom rlp.Encoder
└→ MorphTx.encode(buf) // same function as Path 1

EncodeRLP ensures that Transaction.Hash() (which internally calls rlp.Encode) produces output consistent with MarshalBinary.

4.6 Decoding Paths

Path 1: Transaction.UnmarshalBinary(bytes)
└→ Transaction.decodeTyped(bytes)
├→ Check bytes[0] == 0x7F
├→ inner = new(MorphTx)
└→ inner.decode(bytes[1:]) // version detection + decode

Path 2: rlp.Decode(reader, &morphTx)
└→ MorphTx.DecodeRLP(stream) // custom rlp.Decoder
├→ stream.Kind() == List → V0
└→ stream.Uint8() + stream.Raw() → V1

Why DecodeRLP is necessary: Go struct field order ([...AccessList, Version, FeeTokenID, ...]) doesn't match V0 wire format ([...AccessList, FeeTokenID, ...]). Reflection-based decoding would cause type overflow errors.

4.7 Transaction Hash

V0: keccak256(0x7F || rlp.Encode(morphTx))
= keccak256(0x7F || RLP([14 fields including signature]))

V1: keccak256(0x7F || rlp.Encode(morphTx))
= keccak256(0x7F || 0x01 || RLP([16 fields including signature]))

Note: rlp.Encode(morphTx) calls the custom EncodeRLP, so V1 output includes the 0x01 prefix.


5. Signing Mechanism

5.1 Signer Requirements

MorphTx must be signed with an Emerald or higher-level signer:

// Recommended
signer := types.NewEmeraldSigner(chainID)

// Or auto-selected by protocol (Emerald must be active)
signer := types.MakeSigner(config, blockNumber, blockTime)

// LatestSignerForChainID returns NewEmeraldSigner
signer := types.LatestSignerForChainID(chainID)

5.2 Signing Hash Calculation

The signing hash is the digest used for signing/verification — it does NOT include V/R/S signature values.

V0 signing hash (11 fields):

sigHash = keccak256(0x7F || rlp([
chainID, nonce, gasTipCap, gasFeeCap, gas, to, value, data,
accessList, feeTokenID, feeLimit
]))

V1 signing hash (14 fields):

sigHash = keccak256(0x7F || rlp([
chainID, nonce, gasTipCap, gasFeeCap, gas, to, value, data,
accessList, feeTokenID, feeLimit, version, reference, memo
]))

V1 adds version, reference, and memo compared to V0.

5.3 Signing Flow

// 1. Construct unsigned transaction
tx := types.NewTx(&types.MorphTx{
ChainID: chainID,
Nonce: nonce,
GasTipCap: gasTipCap,
GasFeeCap: gasFeeCap,
Gas: gasLimit,
To: &toAddr,
Value: value,
Data: data,
FeeTokenID: 1,
FeeLimit: feeLimit,
Version: types.MorphTxVersion0,
})

// 2. Sign
signer := types.NewEmeraldSigner(chainID)
signedTx, err := types.SignTx(tx, signer, privateKey)

Internal flow:

types.SignTx(tx, signer, privateKey)
├→ h = signer.Hash(tx) // modernSigner.Hash
│ └→ tx.inner.sigHash(signer.chainID) // MorphTx.sigHash
│ └→ v0SigHash or v1SigHash // prefixedRlpHash(0x7F, [...])
├→ sig = crypto.Sign(h[:], privateKey) // 65-byte signature
└→ tx.WithSignature(signer, sig)
└→ signer.SignatureValues(tx, sig)
├→ R = sig[0:32]
├→ S = sig[32:64]
└→ V = sig[64] (0 or 1)

5.4 V/R/S Specification

  • V value: 0 or 1 (recovery identifier), same as DynamicFeeTx and other modern tx types
  • Sender recovery: V + 27 to convert to Homestead format, then call ecrecover
  • Signature encoding: Standard 65 bytes: R (32 bytes) || S (32 bytes) || V (1 byte)

6. RPC Interfaces

6.1 Standard eth Namespace

eth_sendTransaction

Constructs MorphTx via TransactionArgs. Supported Morph-specific fields:

{
"from": "0x...",
"to": "0x...",
"maxFeePerGas": "0x...",
"maxPriorityFeePerGas": "0x...",
"gas": "0x...",
"value": "0x...",
"data": "0x...",
"feeTokenID": "0x1",
"feeLimit": "0x...",
"version": "0x1",
"reference": "0x1234...cdef",
"memo": "0x68656c6c6f"
}

Type auto-detection: The transaction is identified as MorphTx when ANY of these fields are set:

  • feeTokenID > 0
  • version is explicitly specified
  • reference is non-empty and non-zero
  • memo is non-empty

Version auto-inference (setDefaults):

  1. If version is explicitly specified → use it and validate params
  2. If V1-only fields exist (reference/memo) → auto-set to V1
  3. Otherwise → default to V0
warning

If you set both gasPrice AND MorphTx fields (e.g., feeTokenID), gasPrice will override the type detection and produce a LegacyTx instead of MorphTx, silently discarding all Morph fields. Always use maxFeePerGas/maxPriorityFeePerGas with MorphTx.

eth_sendRawTransaction

Accepts a signed transaction in RLP-encoded type envelope format:

0x7F || inner_payload

The node automatically decodes via UnmarshalBinarydecodeTypedMorphTx.decode.

eth_getTransactionByHash / eth_getTransactionByBlockHashAndIndex

Returns RPCTransaction with MorphTx fields:

{
"type": "0x7f",
"chainId": "0xb02",
"nonce": "0x1",
"maxFeePerGas": "0x...",
"maxPriorityFeePerGas": "0x...",
"gas": "0x...",
"to": "0x...",
"value": "0x...",
"input": "0x...",
"accessList": [],
"feeTokenID": "0x1",
"feeLimit": "0x...",
"version": "0x1",
"reference": "0x1234...cdef",
"memo": "0x68656c6c6f",
"v": "0x0",
"r": "0x...",
"s": "0x..."
}

Note: version, reference, memo fields only appear for V1+ transactions (omitempty). V0 transactions only return feeTokenID and feeLimit.

eth_getTransactionReceipt

Morph extension fields in the receipt:

{
"feeTokenID": "0x1",
"feeLimit": "0x...",
"feeRate": "0x...",
"tokenScale": "0x...",
"version": "0x0",
"reference": null,
"memo": null,
"l1Fee": "0x..."
}

eth_estimateGas

Gas estimation for MorphTx additionally considers:

  • Alt Fee token balance and FeeLimit constraint
  • L1 Data Fee conversion to token units
  • Whether the token is in active state

eth_call

Supports passing Morph fields in CallMsg for simulated execution.

6.2 morph Namespace (Public)

morph_getTransactionHashesByReference

Query transactions associated with a Reference, with pagination support.

Parameters:

{
"reference": "0x1234...cdef",
"offset": "0x0",
"limit": "0x64"
}

Constraints:

  • limit: max 100
  • offset: max 10000 (prevents linear scan DoS)

Returns: sorted by blockTimestamp + txIndex ascending:

[
{
"transactionHash": "0x...",
"blockNumber": "0x123",
"blockTimestamp": "0x...",
"transactionIndex": "0x0"
}
]

6.3 morph Namespace (Private/Internal)

morph_estimateL1DataFee

Estimates L1 data fee for a transaction. Input is TransactionArgs — Morph fields affect the serialized transaction size and thus the L1 fee.


7. Go SDK Development Guide

7.1 Using ethclient

client, _ := ethclient.Dial("http://localhost:8545")

// Query transactions by Reference
results, err := client.GetTransactionHashesByReference(ctx,
common.HexToReference("0x1234..."),
nil, // offset, default 0
nil, // limit, default 100
)

7.2 Using Contract Bindings (accounts/abi/bind)

Source: accounts/abi/bind/base.go

Morph fields in TransactOpts:

type TransactOpts struct {
// ... standard fields ...
FeeTokenID uint16
FeeLimit *big.Int
Version *uint8 // nil = auto-detect
Reference *common.Reference
Memo *[]byte
}

Usage example:

auth, _ := bind.NewKeyedTransactorWithChainID(key, chainID)
auth.FeeTokenID = 1
auth.FeeLimit = big.NewInt(1000000)

// V1 transaction (with Reference and Memo)
v1 := types.MorphTxVersion1
auth.Version = &v1
ref := common.HexToReference("0x1234...")
auth.Reference = &ref
memo := []byte("payment for order #123")
auth.Memo = &memo

Auto-detect logic (morphTxVersion):

if Version == nil:
if Reference or Memo exists → V1
else → V0
else:
Use specified version and validate parameter compatibility

Transaction type auto-selection: When FeeTokenID != 0 or Version != nil or Reference/Memo exists, BoundContract.transact() calls createMorphTx() instead of createDynamicTx().

7.3 Direct Transaction Construction

// V0 Example: Pay gas with token ID=1
tx := types.NewTx(&types.MorphTx{
ChainID: big.NewInt(2818),
Nonce: 1,
GasTipCap: big.NewInt(1e9),
GasFeeCap: big.NewInt(2e9),
Gas: 21000,
To: &toAddr,
Value: big.NewInt(1e18),
FeeTokenID: 1,
FeeLimit: big.NewInt(5e17),
Version: types.MorphTxVersion0,
})

// V1 Example: Pay with ETH + attach Reference and Memo
ref := common.HexToReference("0xabcd...")
memo := []byte("hello morph")
tx := types.NewTx(&types.MorphTx{
ChainID: big.NewInt(2818),
Nonce: 1,
GasTipCap: big.NewInt(1e9),
GasFeeCap: big.NewInt(2e9),
Gas: 21000,
To: &toAddr,
Value: big.NewInt(1e18),
FeeTokenID: 0, // ETH
Version: types.MorphTxVersion1,
Reference: &ref,
Memo: &memo,
})

// Sign
signer := types.NewEmeraldSigner(big.NewInt(2818))
signedTx, _ := types.SignTx(tx, signer, privateKey)

// Serialize (for sending raw transaction)
rawBytes, _ := signedTx.MarshalBinary()

7.4 Using CallMsg

msg := ethereum.CallMsg{
From: fromAddr,
To: &toAddr,
Gas: 0,
GasFeeCap: big.NewInt(2e9),
GasTipCap: big.NewInt(1e9),
Value: big.NewInt(0),
Data: calldata,
FeeTokenID: 1,
FeeLimit: big.NewInt(5e17),
Version: types.MorphTxVersion0,
}

8. Other Language SDK Guide

8.1 Transaction Type Identifier

  • EIP-2718 type byte: 0x7F (127)
  • This is a typed transaction following the standard envelope format

8.2 Encoding Implementation

V0 Encoding

raw_bytes = [0x7F] + rlp_encode([
chain_id, # big integer
nonce, # uint64
max_priority_fee, # big integer (gasTipCap)
max_fee, # big integer (gasFeeCap)
gas_limit, # uint64
to, # 20 bytes or empty (contract creation)
value, # big integer
data, # bytes
access_list, # [[address, [storage_keys]], ...]
fee_token_id, # uint16 (MUST be > 0)
fee_limit, # big integer
v, # big integer (0 or 1)
r, # big integer
s, # big integer
])

V1 Encoding

raw_bytes = [0x7F, 0x01] + rlp_encode([
chain_id,
nonce,
max_priority_fee,
max_fee,
gas_limit,
to,
value,
data,
access_list,
fee_token_id, # uint16 (can be 0)
fee_limit, # big integer
reference, # bytes (0 or 32 bytes)
memo, # bytes (0~64 bytes)
v,
r,
s,
])

8.3 Signing Implementation

V0 Signing Hash

sig_hash = keccak256(
[0x7F] + rlp_encode([
chain_id, nonce, max_priority_fee, max_fee, gas_limit,
to, value, data, access_list, fee_token_id, fee_limit
])
)

V1 Signing Hash

sig_hash = keccak256(
[0x7F] + rlp_encode([
chain_id, nonce, max_priority_fee, max_fee, gas_limit,
to, value, data, access_list, fee_token_id, fee_limit,
version, reference, memo
])
)

Key differences:

  • V0 signing hash does not include version/reference/memo
  • V1 signing hash includes version (value=1), reference (pointer may be nil), memo (pointer may be nil)
  • In Go, nil pointers encode as RLP empty string (0x80) — other language SDKs must handle this correctly

Signing & Recovery

  • Use standard secp256k1 ECDSA to sign the sig_hash
  • V value is the recovery identifier: 0 or 1 (NOT 27/28)
  • To recover the sender address, add 27 to V and use ecrecover

8.4 Decoding Implementation

# Pseudocode

def decode_morph_tx(raw_bytes):
assert raw_bytes[0] == 0x7F # type byte
payload = raw_bytes[1:]

first_byte = payload[0]
if first_byte >= 0xC0 or first_byte == 0x00:
# V0: direct RLP decode
fields = rlp_decode(payload) # 14 fields
assert fields[9] > 0 # feeTokenID must be non-zero
return MorphTxV0(fields)

elif first_byte == 0x01:
# V1: skip version byte
fields = rlp_decode(payload[1:]) # 16 fields
reference = fields[11] # 0 or 32 bytes
memo = fields[12] # 0~64 bytes
return MorphTxV1(fields)

else:
raise Error("unsupported version")

8.5 JSON-RPC Field Mapping

Go FieldJSON FieldTypeDescription
GasTipCapmaxPriorityFeePerGashex big intEIP-1559 priority fee
GasFeeCapmaxFeePerGashex big intEIP-1559 fee cap
FeeTokenIDfeeTokenIDhex uint16Token ID
FeeLimitfeeLimithex big intToken fee cap
Versionversionhex uint64Version (V1+ only)
Referencereferencehex bytes32Reference key (V1+ only)
Memomemohex bytesMemo (V1+ only)

Note on type differences:

  • TransactionArgs (sending): version is hexutil.Uint16
  • RPCTransaction (querying): version is hexutil.Uint64
  • txJSON (JSON serialization): version is hexutil.Uint64

9. Alt Fee Mechanism

9.1 Concept

Alt Fee allows users to pay transaction gas fees using on-chain registered ERC-20 tokens (e.g., USDT, USDC) instead of ETH. Unlike Paymaster-based models that require extra contract calls and external bundlers, Alt Fee is handled directly at the protocol level, reducing overhead and improving efficiency.

9.2 Key Fields

FieldTypeDescription
FeeTokenIDuint16Token's on-chain registry ID (0 = ETH, > 0 = token)
FeeLimit*big.IntMaximum token amount the user is willing to pay

9.3 Fee Conversion

The system follows EIP-1559 to calculate fees in ETH, then converts to the selected token using an on-chain oracle rate:

tokenAmount = ⌈(ethAmount × tokenScale) / tokenRate⌉

Ceiling rounding ensures the token amount always fully covers the ETH fee.

Token rates are maintained by oracle services and stored in the Token Registry system contract at:

0x5300000000000000000000000000000000000021

9.4 Constraint Rules

  1. V0 must use Alt Fee: FeeTokenID > 0
  2. V1 Alt Fee is optional: FeeTokenID can be 0 (use ETH)
  3. FeeLimit is linked to FeeTokenID: If FeeTokenID == 0, then FeeLimit must be nil/0
  4. Token must be active: Tx pool and execution check whether the token is in active state

9.5 Alt Fee in Gas Estimation

When using eth_estimateGas:

  • If FeeTokenID is set, the node queries the user's token balance instead of ETH balance
  • L1 Data Fee is converted to token units at the current exchange rate
  • FeeLimit participates in the payable gas cap calculation

9.6 Fee Info in Receipts

After execution, the receipt contains:

  • feeRate: ETH/token exchange rate used at execution time
  • tokenScale: Exchange rate precision scale
  • l1Fee: L1 data fee

10. Reference Index System

10.1 Concept

Reference is a 32-byte on-chain index key that allows users to associate multiple transactions with the same identifier, queryable via RPC.

10.2 Storage Mechanism

Source: core/rawdb/accessors_reference_index.go

  • Write timing: When a block is successfully inserted into the chain (core/blockchain.gomaintainReferenceIndex)
  • Index key format: prefix + reference(32B) + blockTimestamp(8B) + txIndex(8B) + txHash(32B)
  • Value: Empty (leverages key ordering for range queries)
  • Sort order: Naturally ascending by blockTimestamp + txIndex

10.3 Querying

Use morph_getTransactionHashesByReference RPC (see Section 6.2).

10.4 Use Cases

  • Order systems: Hash an order ID as Reference, query all related transactions
  • Batch operations: Use a batch identifier as Reference, track all transactions in a batch
  • Cross-system correlation: Map external system IDs to References

10.5 Important Notes

  • Reference is only available in V1 MorphTx
  • Reference indexing begins after the Jade fork
  • The same Reference can be used by multiple transactions
  • Index auto-updates with chain reorgs (written on insertion, deleted on rollback)

11. Receipt Extensions

Source: core/state_processor.go, core/types/receipt.go

MorphTx receipts include these extension fields:

FieldTypeConditionDescription
FeeTokenID*uint16All MorphTxToken ID used
FeeLimit*big.IntAll MorphTxToken fee cap
FeeRate*big.IntAll MorphTxETH/token exchange rate at execution
TokenScale*big.IntAll MorphTxExchange rate precision scale
L1Fee*big.IntAll MorphTxL1 data fee
Versionuint8V1+ onlyTransaction version
Reference*common.ReferenceV1+ onlyReference key
Memo*[]byteV1+ onlyMemo

12. Common Pitfalls

Pitfall 1: gasPrice Overrides MorphTx Type Detection

Problem: In eth_sendTransaction params, if you set both gasPrice AND MorphTx fields (e.g., feeTokenID), gasPrice overrides the final type to LegacyTxType, silently discarding all Morph fields.

// internal/ethapi/transaction_args.go → toTransaction()
usedType := types.LegacyTxType
switch {
case ..MorphTx conditions..:
usedType = types.MorphTxType // ① First identified as MorphTx
...
}
if args.GasPrice != nil {
usedType = types.LegacyTxType // ② But gasPrice overrides!
}

Solution: Always use maxFeePerGas and maxPriorityFeePerGas with MorphTx. Never set gasPrice.

Pitfall 2: V0 vs V1 FeeTokenID Semantics

V0V1
FeeTokenID == 0❌ Invalid✅ Valid (means use ETH)
PurposeAlt Fee onlyGeneral-purpose MorphTx, Alt Fee optional

If migrating from V0 to V1, note that FeeTokenID is no longer required.

Pitfall 3: V1 Signing Hash Includes nil Pointer RLP Encoding

In the V1 signing hash, reference and memo participate in RLP encoding even when nil (nil encodes as 0x80). Other language SDK implementations must handle this correctly.

Pitfall 4: Submitting V1 Transactions Before Jade Fork

Before Jade fork activation:

  • eth_sendTransaction's setDefaults will directly reject
  • eth_sendRawTransaction will be rejected at tx pool entry
  • Even if you can construct a V1 transaction, block validation will reject it

Pitfall 5: RPC version Field Only Appears for V1+

V0 transactions' RPCTransaction does NOT include version, reference, or memo fields (omitempty). SDKs must handle missing fields:

  • If version is missing → default to V0
  • If reference is missing → nil
  • If memo is missing → nil

Pitfall 6: V0 Encoding Rejects FeeTokenID == 0

V0 format validates FeeTokenID != 0 at both encoding and decoding time:

  • You cannot construct a V0 transaction with FeeTokenID == 0
  • If you receive a transaction claiming to be V0 but with FeeTokenID == 0, decoding will fail

13. Appendix: Full Call Path Diagrams

13.1 Send Transaction (eth_sendTransaction)

User JSON-RPC request


PublicTransactionPoolAPI.SendTransaction()

├→ args.setDefaults(ctx, backend)
│ ├→ Fill maxFeePerGas / maxPriorityFeePerGas (EIP-1559)
│ ├→ Fill nonce
│ ├→ isMorphTxArgs() → true?
│ │ ├→ Check Jade fork status
│ │ ├→ Infer version (V0/V1)
│ │ └→ validateMorphTxVersion()
│ ├→ Validate memo length
│ └→ DoEstimateGas() → estimate gas (with Alt Fee logic)

├→ args.toTransaction()
│ └→ Construct types.MorphTx{...}
│ └→ types.NewTx(data)

├→ wallet.SignTx(account, tx, chainID)
│ └→ keystore.SignTx()
│ └→ types.SignTx(tx, LatestSignerForChainID(chainID), key)
│ └→ NewEmeraldSigner → modernSigner.Hash → MorphTx.sigHash

└→ SubmitTransaction()
└→ backend.SendTx() → enters tx pool
└→ TxPool.validateTx()
├→ Check eip1559 active
├→ Check Jade fork (V1)
├→ ValidateMorphTxVersion()
├→ Check token active (Alt Fee)
└→ Check balance (ETH or token)

13.2 Send Raw Transaction (eth_sendRawTransaction)

User submits raw bytes (0x7F || inner_payload)


PublicTransactionPoolAPI.SendRawTransaction()

├→ tx.UnmarshalBinary(rawBytes)
│ └→ decodeTyped(rawBytes)
│ ├→ rawBytes[0] == 0x7F → inner = new(MorphTx)
│ └→ inner.decode(rawBytes[1:])
│ ├→ First byte version detection
│ ├→ V0: decodeV0MorphTxRLP()
│ └→ V1: decodeV1MorphTxRLP()

└→ SubmitTransaction()
└→ (same as above)

13.3 Query Transaction

eth_getTransactionByHash


NewRPCTransaction(tx, ...)
├→ types.Sender(signer, tx) → recover sender
├→ tx.RawSignatureValues() → V, R, S
├→ case types.MorphTxType:
│ ├→ Fill accessList, chainID, gasFeeCap, gasTipCap
│ ├→ Fill feeTokenID, feeLimit
│ └→ if tx.Version() >= V1:
│ └→ Fill version, reference, memo
└→ Return RPCTransaction JSON

13.4 Block Validation

BlockValidator.ValidateBody(block)

├→ isJadeFork = config.IsJadeFork(block.Time())

└→ for tx in block.Transactions():
├→ if !isJadeFork && tx.IsMorphTx() && tx.Version() == V1:
│ └→ return ErrMorphTxV1NotYetActive
└→ tx.ValidateMorphTxVersion()
└→ Check version/field validity

13.5 Execution & Receipt Generation

StateProcessor.applyTransaction(tx)

├→ tx.AsMessage(signer, baseFee)
│ ├→ ValidateMorphTxVersion()
│ └→ Construct Message{feeTokenID, version, reference, memo, ...}

├→ ApplyMessage(evm, msg, gasPool)
│ └→ StateTransition.Execute()
│ ├→ buyGas() → if Alt Fee, query token balance & rate
│ ├→ EVM execution
│ └→ refundGas() → refund unused gas (ETH or token)

└→ Build Receipt
├→ receipt.FeeTokenID = tx.FeeTokenID()
├→ receipt.FeeLimit = tx.FeeLimit()
├→ receipt.FeeRate = result.FeeRate
├→ receipt.TokenScale = result.TokenScale
├→ receipt.L1Fee = result.L1DataFee
└→ if tx.Version() >= V1:
├→ receipt.Version = tx.Version()
├→ receipt.Reference = tx.Reference()
└→ receipt.Memo = tx.Memo()

Appendix B: Source File Index

File PathResponsibility
core/types/morph_tx.goMorphTx struct, encoding/decoding, signing hash
core/types/transaction.goTxData interface, type constant (0x7F), version validation, AsMessage
core/types/transaction_signing.goSigner interface, modernSigner, SignTx, NewEmeraldSigner
core/types/transaction_marshalling.goJSON serialization/deserialization
core/types/receipt.goReceipt struct and Morph extension fields
core/types/hashing.goprefixedRlpHash
internal/ethapi/transaction_args.goRPC param handling, version inference, validation
internal/ethapi/api.goRPCTransaction, SendTransaction, Receipt serialization, PublicMorphAPI
internal/ethapi/backend.gomorph namespace registration
core/tx_pool.goTx pool validation, Jade fork check, V1 cleanup
core/block_validator.goBlock validation MorphTx checks
core/state_processor.goPost-execution receipt population
core/state_transition.goAlt Fee deduction and refund logic
core/blockchain.goReference index maintenance
core/rawdb/accessors_reference_index.goReference index read/write
accounts/abi/bind/base.goContract binding TransactOpts and createMorphTx
accounts/external/backend.goExternal signer MorphTx field passing
signer/core/apitypes/types.goClef signer SendTxArgs.ToTransaction
ethclient/ethclient.goGo client wrapper
rpc/types.goReferenceQueryArgs / ReferenceTransactionResult
rollup/fees/rollup_fee.goL1 data fee calculation with MorphTx serialization
params/config.goEmeraldTime, JadeForkTime configuration
common/types.goReference type, MaxMemoLength
interfaces.goCallMsg Morph fields