Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .github/workflows/pr_lint_e2e.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
name: Lint E2E

on:
pull_request:
paths:
- 'tests/**'
workflow_dispatch:

permissions:
contents: read

jobs:
lint-e2e:
name: PR Lint (E2E)
runs-on: ubuntu-22.04

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: "1.24"
cache: true

- name: Run golangci-lint for E2E tests
uses: golangci/golangci-lint-action@v8
with:
version: v2.9.0
args: --timeout=5m --build-tags e2e ./tests/e2e/...
23 changes: 21 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.PHONY: build test clean run setup db-up db-down docker-build docker-run deploy-contracts install-mockery check-mockery generate-mocks
.PHONY: build test clean run setup db-up db-down docker-build docker-run deploy-contracts install-mockery check-mockery generate-mocks test-e2e test-e2e-api test-e2e-bridge test-e2e-indexer lint lint-e2e

MOCKERY_VERSION ?= v2.53.6

Expand Down Expand Up @@ -37,9 +37,14 @@ get_lint:
curl -sfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s v2.9.0; \
fi;

# Run linter
# Run linter (main code + E2E tests)
lint: get_lint
./bin/golangci-lint run
./bin/golangci-lint run --build-tags e2e ./tests/e2e/...

# Run linter for E2E tests only
lint-e2e: get_lint
./bin/golangci-lint run --build-tags e2e ./tests/e2e/...

# Format code
fmt:
Expand Down Expand Up @@ -107,3 +112,17 @@ setup: deps db-up
$(MAKE) db-migrate
cp config.example.yaml config.yaml
@echo "Setup complete! Edit config.yaml and run 'make run'"

# E2E tests
E2E_COMPOSE := tests/e2e/docker-compose.e2e.yaml

test-e2e: test-e2e-api test-e2e-bridge test-e2e-indexer

test-e2e-api:
@echo "not yet implemented"

test-e2e-bridge:
@echo "not yet implemented"

test-e2e-indexer:
@echo "not yet implemented"
217 changes: 217 additions & 0 deletions tests/e2e/devstack/stack/interfaces.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
//go:build e2e

// Package stack defines the service interfaces and shared types for the E2E
// test framework. Every layer above this one (shim, system, dsl, presets)
// depends only on these interfaces — never on concrete implementations —
// so test code remains decoupled from network transport details.
package stack

import (
"context"
"math/big"

"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/ethclient"

"github.com/chainsafe/canton-middleware/pkg/indexer"
"github.com/chainsafe/canton-middleware/pkg/registry"
"github.com/chainsafe/canton-middleware/pkg/relayer"
"github.com/chainsafe/canton-middleware/pkg/transfer"
"github.com/chainsafe/canton-middleware/pkg/user"
)

// Anvil is the interface for the local Anvil Ethereum node.
// It provides EVM interaction helpers used by deposit and balance checks.
type Anvil interface {
// Endpoint returns the HTTP JSON-RPC URL (e.g. "http://localhost:8545").
Endpoint() string

// RPC returns a connected go-ethereum client for direct low-level calls.
RPC() *ethclient.Client

// ChainID returns the Anvil chain ID (31337 for local devnet).
ChainID() *big.Int

// ERC20Balance returns the on-chain ERC-20 balance of owner for the given
// token contract address.
ERC20Balance(ctx context.Context, tokenAddr, owner common.Address) (*big.Int, error)

// ApproveAndDeposit approves the bridge contract to spend amount, then
// submits the deposit transaction. Returns the transaction hash.
ApproveAndDeposit(ctx context.Context, account *Account, amount *big.Int) (common.Hash, error)
}

// Canton is the interface for the Canton ledger node.
// It provides endpoint accessors and a liveness check.
type Canton interface {
// GRPCEndpoint returns the gRPC endpoint (e.g. "localhost:5011").
GRPCEndpoint() string

// HTTPEndpoint returns the Canton HTTP JSON API endpoint
// (e.g. "http://localhost:5013").
HTTPEndpoint() string

// IsHealthy returns true when Canton has connected to the synchronizer
// and is ready to accept commands.
IsHealthy(ctx context.Context) bool
}

// APIServer is the interface for the canton-middleware api-server.
// It covers user registration (web3 custodial and non-custodial external
// modes), the two-step Canton transfer flow, ERC-20 balance queries through
// the Ethereum JSON-RPC facade, and the Splice registry endpoint.
//
// Request and response types are reused directly from the service packages
// (pkg/user, pkg/transfer) to avoid duplication and ensure the E2E layer
// always reflects the real API contract.
type APIServer interface {
// Endpoint returns the base HTTP URL (e.g. "http://localhost:8081").
Endpoint() string

// Health returns nil when the api-server is ready to accept requests.
Health(ctx context.Context) error

// Register registers an EVM account in custodial web3 mode via
// POST /register. req.Signature is an EIP-191 personal_sign signature;
// req.Message is the plain-text string that was signed. The server
// recovers the EVM address and allocates a Canton party.
Register(ctx context.Context, req *user.RegisterRequest) (*user.RegisterResponse, error)

// PrepareTopology is the first step of non-custodial (external key) user
// registration via POST /register/prepare-topology. req.CantonPublicKey
// is the hex-encoded compressed secp256k1 Canton public key. The response
// carries the topology hash the client must sign and a short-lived
// registration token.
PrepareTopology(ctx context.Context, req *user.RegisterRequest) (*user.PrepareTopologyResponse, error)

// RegisterExternal completes non-custodial registration via POST /register
// with key_mode=external. req must include the RegistrationToken and
// TopologySignature from PrepareTopology.
RegisterExternal(ctx context.Context, req *user.RegisterRequest) (*user.RegisterResponse, error)

// PrepareTransfer initiates a Canton token transfer via
// POST /api/v2/transfer/prepare. account is used by the shim to produce
// the required timed EIP-191 auth headers (X-Signature, X-Message);
// req carries the transfer body (To, Amount, Token).
// Returns the transaction hash the sender must sign and an opaque
// transfer ID.
PrepareTransfer(ctx context.Context, account *Account, req *transfer.PrepareRequest) (*transfer.PrepareResponse, error)

// ExecuteTransfer completes a prepared transfer via
// POST /api/v2/transfer/execute. account is used by the shim to produce
// the timed EIP-191 auth headers; req carries the DER-encoded Canton
// signature of the transaction hash from PrepareTransfer.
ExecuteTransfer(ctx context.Context, account *Account, req *transfer.ExecuteRequest) (*transfer.ExecuteResponse, error)

// ERC20Balance returns the ERC-20 balance of ownerAddr for tokenAddr by
// calling eth_call through the api-server's Ethereum JSON-RPC facade at
// POST /eth.
ERC20Balance(ctx context.Context, tokenAddr, ownerAddr string) (string, error)

// TransferFactory calls POST /registry/transfer-instruction/v1/transfer-factory
// and returns the base64-encoded CreatedEventBlob used for Splice contract
// discovery.
TransferFactory(ctx context.Context) (*registry.TransferFactoryResponse, error)
}

// Relayer is the interface for the canton-bridge relayer service.
// It exposes health, readiness, and transfer query operations.
type Relayer interface {
// Endpoint returns the base HTTP URL (e.g. "http://localhost:8080").
Endpoint() string

// Health returns nil when the relayer HTTP server is up.
Health(ctx context.Context) error

// IsReady returns true when the relayer engine has synced both the Canton
// and Ethereum event streams and is actively processing transfers.
IsReady(ctx context.Context) bool

// ListTransfers returns all bridge transfers tracked by the relayer
// (up to the server-side limit of 100).
ListTransfers(ctx context.Context) ([]*relayer.Transfer, error)

// GetTransfer returns a single transfer by its opaque ID.
GetTransfer(ctx context.Context, id string) (*relayer.Transfer, error)

// Status returns the relayer's reported status string (e.g. "running").
Status(ctx context.Context) (string, error)
}

// Indexer is the interface for the Canton token-transfer event indexer.
// All methods target the unauthenticated admin read API at
// /indexer/v1/admin, which is intended for internal backend access only.
//
// Paginated list methods accept 1-based page numbers and a limit between
// 1 and 200 (server-enforced). Response types are reused directly from
// pkg/indexer.
type Indexer interface {
// Endpoint returns the base HTTP URL (e.g. "http://localhost:8082").
Endpoint() string

// Health returns nil when the indexer is running and streaming from the
// Canton ledger.
Health(ctx context.Context) error

// GetToken returns the current state of a token identified by its issuer
// party (admin) and instrument ID (id).
GetToken(ctx context.Context, admin, id string) (*indexer.Token, error)

// TotalSupply returns the current total supply as a decimal string for
// the token identified by admin and id.
TotalSupply(ctx context.Context, admin, id string) (string, error)

// ListTokens returns a paginated list of all tokens indexed so far.
ListTokens(ctx context.Context, page, limit int) (*indexer.Page[*indexer.Token], error)

// GetBalance returns the current holding of partyID for the token
// identified by admin and id.
GetBalance(ctx context.Context, partyID, admin, id string) (*indexer.Balance, error)

// ListBalancesForParty returns all instrument balances held by partyID.
ListBalancesForParty(ctx context.Context, partyID string, page, limit int) (*indexer.Page[*indexer.Balance], error)

// GetBalanceForToken returns a paginated list of all party balances for
// the token identified by admin and id.
GetBalanceForToken(ctx context.Context, admin, id string, page, limit int) (*indexer.Page[*indexer.Balance], error)

// GetEvent returns a single indexed event by its Canton contract ID.
GetEvent(ctx context.Context, contractID string) (*indexer.ParsedEvent, error)

// ListPartyEvents returns events in which partyID appears as sender or
// receiver. eventType filters to indexer.EventMint, EventBurn,
// EventTransfer, or "" for all types.
ListPartyEvents(
ctx context.Context,
partyID string,
eventType indexer.EventType,
page, limit int,
) (*indexer.Page[*indexer.ParsedEvent], error)

// ListTokenEvents returns all events for the token identified by admin
// and id. eventType filters to indexer.EventMint, EventBurn,
// EventTransfer, or "" for all types.
ListTokenEvents(
ctx context.Context,
admin, id string,
eventType indexer.EventType,
page, limit int,
) (*indexer.Page[*indexer.ParsedEvent], error)
}

// APIDatabase is the interface for direct access to the api-server's database
// during E2E tests. It is used for setup (whitelisting test addresses) and
// assertions (verifying user records written by the api-server).
type APIDatabase interface {
// DSN returns the api-server PostgreSQL connection string
// (e.g. "postgres://postgres:p@ssw0rd@localhost:5432/erc20_api").
DSN() string

// WhitelistAddress inserts evmAddress into the whitelist table, granting
// it permission to register with the api-server.
WhitelistAddress(ctx context.Context, evmAddress string) error

// GetUserByEVMAddress returns the user row for evmAddress, or nil if no
// row exists.
GetUserByEVMAddress(ctx context.Context, evmAddress string) (*user.User, error)
}
99 changes: 99 additions & 0 deletions tests/e2e/devstack/stack/types.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
//go:build e2e

package stack

import "github.com/ethereum/go-ethereum/common"

// ---------------------------------------------------------------------------
// Test accounts
// ---------------------------------------------------------------------------

// Account represents an EVM test account used in E2E scenarios.
// It is passed to shim methods that need to produce EIP-191 signatures or
// submit Ethereum transactions on behalf of a test user.
type Account struct {
// Address is the 20-byte EVM address derived from PrivateKey.
Address common.Address

// PrivateKey is the hex-encoded raw private key without a 0x prefix.
PrivateKey string
}

// AnvilAccount0 and AnvilAccount1 are the first two deterministic accounts
// produced by Anvil from the standard test mnemonic
// "test test test … test junk". Their keys are publicly known and must
// never be used outside local dev environments.
var (
AnvilAccount0 = Account{
Address: common.HexToAddress("0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"),
PrivateKey: "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80",
}
AnvilAccount1 = Account{
Address: common.HexToAddress("0x70997970C51812dc3A010C7d01b50e0d17dc79C8"),
PrivateKey: "59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d",
}
)

// ---------------------------------------------------------------------------
// Service manifest
// ---------------------------------------------------------------------------

// ServiceManifest holds the localhost endpoints and contract addresses
// resolved by ServiceDiscovery after Docker Compose reports healthy. Tests
// never hard-code addresses; they always read from the manifest.
type ServiceManifest struct {
// AnvilRPC is the Anvil HTTP JSON-RPC URL (e.g. "http://localhost:8545").
AnvilRPC string

// CantonGRPC is the Canton Ledger API gRPC endpoint (e.g. "localhost:5011").
CantonGRPC string

// CantonHTTP is the Canton HTTP JSON API endpoint (e.g. "http://localhost:5013").
CantonHTTP string

// APIHTTP is the api-server base URL (e.g. "http://localhost:8081").
APIHTTP string

// RelayerHTTP is the relayer base URL (e.g. "http://localhost:8080").
RelayerHTTP string

// IndexerHTTP is the indexer base URL (e.g. "http://localhost:8082").
IndexerHTTP string

// OAuthHTTP is the mock OAuth2 server base URL (e.g. "http://localhost:8088").
OAuthHTTP string

// APIDatabaseDSN is the connection string for the api-server database
// (e.g. "postgres://postgres:p@ssw0rd@localhost:5432/erc20_api").
APIDatabaseDSN string

// RelayerDatabaseDSN is the connection string for the relayer database
// (e.g. "postgres://postgres:p@ssw0rd@localhost:5432/relayer").
RelayerDatabaseDSN string

// IndexerDatabaseDSN is the connection string for the indexer database
// (e.g. "postgres://postgres:p@ssw0rd@localhost:5432/canton_indexer").
IndexerDatabaseDSN string

// PromptTokenAddr is the address of the deployed PromptToken ERC-20
// contract (e.g. "0x5FbDB2315678afecb367f032d93F642f64180aa3").
PromptTokenAddr string

// BridgeAddr is the address of the deployed CantonBridge contract.
BridgeAddr string

// PromptInstrumentAdmin is the Canton party ID of the PROMPT token admin,
// used as the first key component for indexer queries.
PromptInstrumentAdmin string

// PromptInstrumentID is the instrument identifier of the PROMPT token
// (e.g. "PROMPT"), matching InstrumentKey.ID in the indexer config.
PromptInstrumentID string

// DemoInstrumentAdmin is the Canton party ID of the DEMO token admin.
DemoInstrumentAdmin string

// DemoInstrumentID is the instrument identifier of the DEMO token
// (e.g. "DEMO").
DemoInstrumentID string
}
Loading
Loading