This guide walks through adding a new cross-chain protocol (e.g. Hyperlane, Stargate) to CrossChain Archive.
Adding a new protocol requires:
- Implementing the
Decoderinterface - Adding contract addresses and event ABIs
- Implementing a
Normalizerresult for the new event types - Registering the decoder in
cmd/entry points - Writing tests
Create a new package under internal/decoder/<protocol>/:
internal/decoder/myprotocol/
├── decoder.go # Decoder interface implementation
├── abi.go # ABI definitions and parsed types
└── decoder_test.go # Unit tests
package myprotocol
import (
"github.com/ethereum/go-ethereum/accounts/abi"
"strings"
)
// MessageSentABI is the ABI fragment for the MessageSent event.
const messageSentABIJSON = `[{
"type": "event",
"name": "MessageSent",
"inputs": [
{"name": "messageId", "type": "bytes32", "indexed": true},
{"name": "origin", "type": "uint32", "indexed": false},
{"name": "destination","type": "uint32", "indexed": false},
{"name": "sender", "type": "bytes32", "indexed": false},
{"name": "recipient", "type": "bytes32", "indexed": false},
{"name": "body", "type": "bytes", "indexed": false}
]
}]`
var messageSentABI abi.ABI
func init() {
var err error
messageSentABI, err = abi.JSON(strings.NewReader(messageSentABIJSON))
if err != nil {
panic("myprotocol: invalid ABI: " + err.Error())
}
}package myprotocol
import (
"fmt"
"github.com/ethereum/go-ethereum/common"
ethtypes "github.com/ethereum/go-ethereum/core/types"
"github.com/Patrick-Ehimen/akave-crosschain-archive/internal/decoder"
)
const Protocol = "myprotocol"
// contractsByChain maps chain IDs to the contracts that emit events for this protocol.
var contractsByChain = map[uint64][]common.Address{
1: {common.HexToAddress("0xMailboxAddress1")},
42161: {common.HexToAddress("0xMailboxAddressArb")},
}
// Decoder implements decoder.Decoder for MyProtocol.
type Decoder struct{}
func New() *Decoder { return &Decoder{} }
// Protocol returns the unique protocol identifier.
func (d *Decoder) Protocol() string { return Protocol }
// ContractAddresses returns the mailbox contract addresses for the given chain.
func (d *Decoder) ContractAddresses(chainID uint64) []common.Address {
return contractsByChain[chainID]
}
// EventTopics returns the event topic hashes this decoder handles.
func (d *Decoder) EventTopics() []common.Hash {
return []common.Hash{
messageSentABI.Events["MessageSent"].ID,
}
}
// Decode parses a raw Ethereum log into a RawEvent.
func (d *Decoder) Decode(log ethtypes.Log, chainID uint64) (*decoder.RawEvent, error) {
if len(log.Topics) == 0 {
return nil, fmt.Errorf("myprotocol: empty topics")
}
switch log.Topics[0] {
case messageSentABI.Events["MessageSent"].ID:
return d.decodeMessageSent(log, chainID)
default:
return nil, fmt.Errorf("myprotocol: unknown topic %s", log.Topics[0])
}
}
func (d *Decoder) decodeMessageSent(log ethtypes.Log, chainID uint64) (*decoder.RawEvent, error) {
// Indexed fields come from Topics; non-indexed from Data.
if len(log.Topics) < 2 {
return nil, fmt.Errorf("myprotocol: MessageSent: not enough topics")
}
messageID := log.Topics[1].Hex()
var parsed struct {
Origin uint32
Destination uint32
Sender [32]byte
Recipient [32]byte
Body []byte
}
if err := messageSentABI.UnpackIntoInterface(&parsed, "MessageSent", log.Data); err != nil {
return nil, fmt.Errorf("myprotocol: unpack MessageSent: %w", err)
}
return &decoder.RawEvent{
Protocol: Protocol,
ChainID: chainID,
BlockNumber: log.BlockNumber,
TxHash: log.TxHash.Hex(),
LogIndex: log.Index,
EventType: "MessageSent",
Data: map[string]string{
"message_id": messageID,
"origin": fmt.Sprintf("%d", parsed.Origin),
"destination": fmt.Sprintf("%d", parsed.Destination),
"sender": common.BytesToAddress(parsed.Sender[:]).Hex(),
"recipient": common.BytesToAddress(parsed.Recipient[:]).Hex(),
},
}, nil
}The normalizer (internal/normalizer/normalizer.go) converts a RawEvent into a Result (either a Message or a CorrelationKey). Add a case for your protocol:
case "myprotocol":
return n.normalizeMyProtocol(event)Implement the function:
func (n *Normalizer) normalizeMyProtocol(event *decoder.RawEvent) (*Result, error) {
switch event.EventType {
case "MessageSent":
msgID := fmt.Sprintf("myprotocol-%s", event.Data["message_id"])
return &Result{
Message: &types.Message{
MessageID: msgID,
Protocol: event.Protocol,
Type: types.MessageTypeMessage,
Status: types.StatusPending,
Source: types.MessageSource{
ChainID: event.ChainID,
TxHash: event.TxHash,
BlockNumber: event.BlockNumber,
Timestamp: event.Timestamp,
Sender: event.Data["sender"],
LogIndex: int(event.LogIndex),
},
},
}, nil
case "MessageDelivered":
// Return a CorrelationKey so the correlator can match to an existing pending message.
msgID := fmt.Sprintf("myprotocol-%s", event.Data["message_id"])
return &Result{
CorrelationKey: &types.CorrelationKey{
MessageID: msgID,
Destination: &types.MessageDestination{
ChainID: event.ChainID,
TxHash: event.TxHash,
BlockNumber: event.BlockNumber,
Timestamp: event.Timestamp,
Receiver: event.Data["recipient"],
LogIndex: int(event.LogIndex),
},
},
}, nil
}
return nil, fmt.Errorf("myprotocol: unknown event type %s", event.EventType)
}In each binary entrypoint, register your decoder alongside the others:
cmd/indexer/main.go and cmd/backfill/main.go:
import "github.com/Patrick-Ehimen/akave-crosschain-archive/internal/decoder/myprotocol"
// ...
registry.Register(myprotocol.New())Add your protocol's contracts to the chain configs in internal/decoder/myprotocol/decoder.go or load them from config if they vary per deployment.
Create internal/decoder/myprotocol/decoder_test.go:
package myprotocol_test
import (
"math/big"
"testing"
"github.com/ethereum/go-ethereum/common"
ethtypes "github.com/ethereum/go-ethereum/core/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/Patrick-Ehimen/akave-crosschain-archive/internal/decoder/myprotocol"
)
func TestDecodeMessageSent(t *testing.T) {
d := myprotocol.New()
// Build a realistic log using ABI encoding (see existing decoder tests for patterns).
log := ethtypes.Log{
BlockNumber: 1000,
TxHash: common.HexToHash("0xabc"),
Index: 0,
Topics: []common.Hash{
// MessageSent event ID
d.EventTopics()[0],
// messageId (indexed)
common.HexToHash("0x" + "01" + strings.Repeat("00", 31)),
},
Data: buildMessageSentData(t, 1, 42161, "0xSender", "0xRecipient"),
}
event, err := d.Decode(log, 1)
require.NoError(t, err)
assert.Equal(t, "myprotocol", event.Protocol)
assert.Equal(t, "MessageSent", event.EventType)
assert.NotEmpty(t, event.Data["message_id"])
}Use ABI encoding to build log.Data (see internal/decoder/layerzero/decoder_test.go for a complete example).
| Pattern | When to use | Example |
|---|---|---|
MessageID direct lookup |
Source and destination share a unique ID | Axelar commandId, CCIP messageId, MyProtocol messageId |
| Composite key | No single shared ID | LayerZero GUID, Wormhole (emitterChain, emitterAddr, sequence) |
The correlator in internal/correlator/correlator.go handles both via the CorrelationKey struct.
-
internal/decoder/<protocol>/decoder.go— implementsdecoder.Decoder -
internal/decoder/<protocol>/abi.go— ABI definitions -
internal/decoder/<protocol>/decoder_test.go— unit tests with ABI-encoded logs -
internal/normalizer/normalizer.go— normalization case added -
cmd/indexer/main.go— decoder registered -
cmd/backfill/main.go— decoder registered - Contract addresses added for all supported chains
- Decoder coverage remains >85% (
make coverage)