diff --git a/.github/workflows/pr_lint_e2e.yml b/.github/workflows/pr_lint_e2e.yml new file mode 100644 index 00000000..951429eb --- /dev/null +++ b/.github/workflows/pr_lint_e2e.yml @@ -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/... \ No newline at end of file diff --git a/Makefile b/Makefile index 32832984..25924e70 100644 --- a/Makefile +++ b/Makefile @@ -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 @@ -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: @@ -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" diff --git a/tests/e2e/devstack/stack/interfaces.go b/tests/e2e/devstack/stack/interfaces.go new file mode 100644 index 00000000..5314bce6 --- /dev/null +++ b/tests/e2e/devstack/stack/interfaces.go @@ -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) +} diff --git a/tests/e2e/devstack/stack/types.go b/tests/e2e/devstack/stack/types.go new file mode 100644 index 00000000..45cc31e8 --- /dev/null +++ b/tests/e2e/devstack/stack/types.go @@ -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 +} diff --git a/tests/e2e/docker-compose.e2e.yaml b/tests/e2e/docker-compose.e2e.yaml new file mode 100644 index 00000000..7057ead8 --- /dev/null +++ b/tests/e2e/docker-compose.e2e.yaml @@ -0,0 +1,10 @@ +# E2E test compose entry point. +# +# Pulls in the full stack from the root compose file. All services already +# expose fixed ports (8545, 5011, 5013, 8080, 8081, 8082, 5432, 8088) so +# ServiceDiscovery can resolve them via `docker compose port`. +# +# Usage: +# docker compose -f tests/e2e/docker-compose.e2e.yaml up --wait +include: + - path: ../../docker-compose.yaml