Skip to content

feat(relayer): multi-token refactor — TokenBridge adapter interface #356

Description

@sadiq1971

Goal

Refactor pkg/relayer from single-token (PROMPT, hardwired) to a mechanism-adapter architecture, with zero behavior change. This is the foundation for USDCx (Circle xReserve) and any future token whose bridge we may or may not operate ourselves.

Design

Full design: docs/bridging-design.md (see epic for the condensed version).

Core abstraction — don't abstract over lock/mint verbs; abstract over the one thing every mechanism shares:

A transfer is a durable record advanced by an idempotent step function until terminal.

type TokenBridge interface {
    Key() string                                          // "wayfinder", "xreserve"
    Sources(ctx context.Context) ([]Source, error)        // event streams (empty for observer mechanisms)
    Step(ctx context.Context, t *relayer.Transfer) (StepResult, error) // advance one stage, never block
}

type StepResult struct {
    Status     relayer.TransferStatus // pending | in_progress | completed | failed
    Stage      string                 // mechanism-defined
    DestTxHash *string
    Metadata   map[string]any         // merged into transfers.metadata
    RetryAfter time.Duration
}

Engine becomes two generic loops:

  • runIngest (one per Source): event -> CreateTransfer (idempotent) -> save offset. Detection only.
  • runDriver (one for all mechanisms): tick -> load non-terminal transfers with next_step_at <= now -> dispatch Step via registry by t.BridgeKey -> persist StepResult. Subsumes Processor.processEvent and the reconcile loop; retry/backoff/stuck-detection written once.

Work items

  • pkg/relayer/bridge.goTokenBridge, Source (unchanged shape), Registry
  • pkg/relayer/config.goTokens map[string]TokenConfig{mechanism, evm_address, decimals, canton{...}} replacing single eth_token_contract; per-token decimals replace const decimalPlaces = 18
  • pkg/relayer/engine/ — split into engine.go (lifecycle), ingest.go, driver.go; delete processor.go + destination.go
  • pkg/relayer/bridges/wayfinder/ — port existing logic: source_eth.go + source_canton.go (moved from engine/source.go), bridge.go with Step:
    • deposit: idempotency check -> CreatePendingDeposit -> ProcessDepositAndMint -> completed (single step, as today)
    • withdrawal: "" -> eth_submitted -> completed; folding CompleteWithdrawal into a stage fixes the existing gap where the retry path skips the post-submit hook
    • resolve token from the deposit event's token field via config (finally using CantonBridge.sol's existing ethereumToCantonToken mappings)
  • pkg/migrations/relayerdb/3_multi_token.gotransfers += bridge_key, token_symbol, stage, metadata jsonb, next_step_at; chain_state keyed by (bridge_key, chain_id)
  • pkg/relayer/store/GetSteppableTransfers, ApplyStep, RecordStepError
  • pkg/app/relayer/server.go — build registry from config (mechanism -> constructor)
  • Regenerate mocks; existing unit + e2e tests pass unchanged

Acceptance

  • PROMPT deposits and withdrawals behave identically on devstack (existing e2e green)
  • Adding a second wayfinder-style token is config-only
  • pkg/relayer/engine has no imports of pkg/cantonsdk/bridge or pkg/ethereum contract bindings (all mechanism-specific code lives under bridges/wayfinder)

Metadata

Metadata

Assignees

No one assigned

    Labels

    CIP-0086: Phase 2Phase 2 multi-token generalizationDeliverable: D3D3: Middleware ServiceType: FeatureAdded to issues and PRs to identify that the change is a new feature.

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions