Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
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
79 changes: 79 additions & 0 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
name: E2E Tests

on:
pull_request:
paths:
- 'cmd/**'
- 'pkg/**'
- 'tests/e2e/**'
- '.github/workflows/e2e.yml'
Comment thread
sadiq1971 marked this conversation as resolved.
- 'docker-compose.yaml'
- 'Makefile'
- 'contracts/**'
- 'go.mod'
- 'go.sum'
push:
branches:
- main
workflow_dispatch:

permissions:
contents: read
repository-projects: read

jobs:
e2e:
name: E2E Tests
runs-on: ubuntu-22.04

steps:
- name: Configure git SSH to HTTPS rewrite
# canton-erc20 and ethereum-wayfinder submodules use SSH URLs (git@github.com:).
# actions/checkout cannot authenticate SSH URLs via its token: input, so we
# rewrite them to HTTPS globally before the recursive submodule checkout.
run: git config --global url."https://${{ secrets.GH_PAT }}@github.com/".insteadOf "git@github.com:"
Comment thread
sadiq1971 marked this conversation as resolved.

- name: Checkout
uses: actions/checkout@v4
with:
submodules: recursive
token: ${{ secrets.GH_PAT }}

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

- name: Checkout canton-docker repository
uses: actions/checkout@v4
with:
repository: chainsafe/canton-docker
token: ${{ secrets.GH_PAT }}
path: canton-docker

- name: Build Canton Docker image
run: |
cd canton-docker
./build_contianer.sh

- name: Download dependencies
run: go mod download

- name: Install Daml SDK
run: |
curl -sSL https://get.daml.com/ | sh -s -- 3.4.8
echo "$HOME/.daml/bin" >> "$GITHUB_PATH"

- name: Build DAML contracts
run: make build-dars

- name: Generate CANTON_MASTER_KEY
run: echo "CANTON_MASTER_KEY=$(openssl rand -base64 32)" >> "$GITHUB_ENV"

- name: Run E2E tests
run: make test-e2e

- name: Stop devstack
if: always()
run: make devstack-down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -126,3 +126,4 @@ docker-compose.remote.yaml

# Binary output from demo
interop-demo
/devstack
32 changes: 20 additions & 12 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-up docker-down deploy-contracts install-mockery check-mockery generate-mocks test-e2e test-e2e-api test-e2e-bridge test-e2e-indexer lint lint-e2e test-coverage test-coverage-check
.PHONY: build test clean run setup db-up db-down docker-up docker-down deploy-contracts install-mockery check-mockery generate-mocks build-dars devstack-up devstack-down test-e2e test-e2e-api test-e2e-bridge test-e2e-indexer lint lint-e2e test-coverage test-coverage-check

GREEN := \033[0;32m
RED := \033[0;31m
Expand Down Expand Up @@ -134,17 +134,25 @@ setup: deps db-up
cp config.example.yaml config.yaml
@echo "Setup complete! Edit config.yaml and run 'make run'"

# E2E tests
test-e2e: test-e2e-api test-e2e-bridge test-e2e-indexer
CANTON_MASTER_KEY := $(or $(CANTON_MASTER_KEY),$(shell openssl rand -base64 32))
export CANTON_MASTER_KEY

test-e2e-api:
CANTON_MASTER_KEY=$${CANTON_MASTER_KEY:-$$(openssl rand -base64 32)} \
go test -v -tags e2e -timeout 10m ./tests/e2e/tests/api/...
build-dars:
./scripts/setup/build-dars.sh

test-e2e-bridge:
CANTON_MASTER_KEY=$${CANTON_MASTER_KEY:-$$(openssl rand -base64 32)} \
go test -v -tags e2e -timeout 15m ./tests/e2e/tests/bridge/...
devstack-up:
go run ./tests/e2e/cmd/devstack up

test-e2e-indexer:
CANTON_MASTER_KEY=$${CANTON_MASTER_KEY:-$$(openssl rand -base64 32)} \
go test -v -tags e2e -timeout 20m ./tests/e2e/tests/indexer/...
devstack-down:
go run ./tests/e2e/cmd/devstack down

test-e2e-api: devstack-up
go test -v -tags e2e -timeout 10m -parallel 4 ./tests/e2e/tests/api/...

test-e2e-bridge: devstack-up
go test -v -tags e2e -timeout 15m -parallel 4 ./tests/e2e/tests/bridge/...

test-e2e-indexer: devstack-up
go test -v -tags e2e -timeout 20m -parallel 4 ./tests/e2e/tests/indexer/...

test-e2e: devstack-up test-e2e-api test-e2e-bridge test-e2e-indexer
3 changes: 2 additions & 1 deletion docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ services:
deployer:
image: ghcr.io/foundry-rs/foundry
container_name: deployer
user: root
depends_on:
anvil:
condition: service_healthy
Expand All @@ -54,7 +55,7 @@ services:
DEPLOYER_PRIVATE_KEY: "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"
RELAYER_ADDRESS: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"
ETH_RPC_URL: "http://anvil:8545"
entrypoint: /bin/sh -c "forge clean; while ! cast block-number --rpc-url http://anvil:8545 > /dev/null 2>&1; do sleep 1; done; forge script script/Deployer.s.sol --rpc-url http://anvil:8545 --broadcast"
entrypoint: /bin/sh -c "forge clean || true; while ! cast block-number --rpc-url http://anvil:8545 > /dev/null 2>&1; do sleep 1; done; forge script script/Deployer.s.sol --rpc-url http://anvil:8545 --broadcast"
networks:
- bridge-network

Expand Down
9 changes: 9 additions & 0 deletions pkg/transfer/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ import (
"strings"
"time"

"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"

apperrors "github.com/chainsafe/canton-middleware/pkg/app/errors"
"github.com/chainsafe/canton-middleware/pkg/cantonsdk/token"
"github.com/chainsafe/canton-middleware/pkg/user"
Expand Down Expand Up @@ -142,6 +145,12 @@ func (s *TransferService) Execute(ctx context.Context, senderEVMAddr string, req
SignedBy: req.SignedBy,
})
if err != nil {
if st, ok := status.FromError(err); ok {
switch st.Code() {
case codes.InvalidArgument, codes.PermissionDenied:
return nil, apperrors.ForbiddenError(err, "signature verification failed")
}
}
return nil, fmt.Errorf("execute transfer: %w", err)
}

Expand Down
27 changes: 27 additions & 0 deletions pkg/transfer/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"google.golang.org/grpc/codes"
grpcstatus "google.golang.org/grpc/status"

apperrors "github.com/chainsafe/canton-middleware/pkg/app/errors"
"github.com/chainsafe/canton-middleware/pkg/cantonsdk/token"
Expand Down Expand Up @@ -213,3 +215,28 @@ func TestTransferService_Execute_TransferExpired(t *testing.T) {
})
assertServiceErrorCategory(t, err, apperrors.CategoryGone)
}

func TestTransferService_Execute_InvalidSignature_ReturnsForbidden(t *testing.T) {
ctx := context.Background()
sender := senderUser()

store := mocks.NewUserStore(t)
store.EXPECT().GetUserByEVMAddress(ctx, sender.EVMAddress).Return(sender, nil).Once()

pt := &token.PreparedTransfer{TransferID: "txn-sig-fail"}
cache := mocks.NewTransferCache(t)
cache.EXPECT().GetAndDelete("txn-sig-fail").Return(pt, nil).Once()

cantonErr := grpcstatus.Error(codes.InvalidArgument, "signature verification failed")
tok := mocks.NewToken(t)
tok.EXPECT().ExecuteTransfer(ctx, mock.Anything).Return(cantonErr).Once()

svc := newTestService(tok, store, cache)

_, err := svc.Execute(ctx, sender.EVMAddress, &ExecuteRequest{
TransferID: "txn-sig-fail",
Signature: "0xdeadbeef",
SignedBy: sender.CantonPublicKeyFingerprint,
})
assertServiceErrorCategory(t, err, apperrors.CategoryForbidden)
}
90 changes: 90 additions & 0 deletions tests/e2e/cmd/devstack/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
// devstack manages the Docker Compose devstack lifecycle for E2E tests.
//
// Usage:
//
// go run ./tests/e2e/cmd/devstack <up|down>
//
// up Starts the E2E devstack and waits for all services to be healthy.
//
// Sets SKIP_CANTON_SIG_VERIFY=true so Canton native registration tests
// work without a real Loop wallet signature.
//
// down Tears down the E2E devstack and removes all volumes.
package main

import (
"context"
"fmt"
"os"
"os/exec"
"strings"
)

const (
composeFile = "tests/e2e/docker-compose.e2e.yaml"
projectName = "canton-e2e"
)

func main() {
if len(os.Args) != 2 {
fmt.Fprintf(os.Stderr, "usage: devstack <up|down>\n")
os.Exit(1)
}
ctx := context.Background()
var err error
switch os.Args[1] {
case "up":
err = up(ctx)
case "down":
err = down(ctx)
default:
fmt.Fprintf(os.Stderr, "unknown subcommand %q: want up or down\n", os.Args[1])
os.Exit(1)
}
if err != nil {
fmt.Fprintf(os.Stderr, "devstack %s: %v\n", os.Args[1], err)
os.Exit(1)
}
}

func up(ctx context.Context) error {
if err := compose(ctx, upEnv(),
"up", "--build", "--wait", "--remove-orphans",
); err != nil {
fmt.Fprintln(os.Stderr, "=== deployer logs ===")
_ = compose(ctx, os.Environ(), "logs", "--no-color", "deployer")
return err
}
return nil
}

func down(ctx context.Context) error {
return compose(ctx, os.Environ(),
"down", "-v", "--remove-orphans",
)
}

// upEnv returns the host environment with SKIP_CANTON_SIG_VERIFY forced to
// "true" so that Canton native registration tests work in the devnet.
func upEnv() []string {
const key = "SKIP_CANTON_SIG_VERIFY"
env := os.Environ()
for i, e := range env {
if strings.HasPrefix(e, key+"=") {
env[i] = key + "=true"
return env
}
}
return append(env, key+"=true")
}

func compose(ctx context.Context, env []string, args ...string) error {
// #nosec G204 -- args are fixed constants from this file, not user input.
cmd := exec.CommandContext(ctx, "docker", append([]string{
"compose", "-f", composeFile, "-p", projectName,
}, args...)...)
cmd.Stdout = os.Stderr
cmd.Stderr = os.Stderr
cmd.Env = env
return cmd.Run()
}
62 changes: 62 additions & 0 deletions tests/e2e/devstack/dsl/dsl.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,12 @@ import (
"math/big"
"net/http"
"strings"
"sync"
"testing"
"time"

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

"github.com/chainsafe/canton-middleware/pkg/keys"
"github.com/chainsafe/canton-middleware/pkg/user"
Expand Down Expand Up @@ -305,3 +307,63 @@ func (d *DSL) Withdraw(ctx context.Context, t *testing.T, partyID, fingerprint,

return withdrawalReqCID
}

// anvilFundingMu serializes all NewFundedAccount calls so that concurrent
// parallel tests never race on AnvilAccount0's nonce. A package-level mutex is
// used because each test creates its own DSL instance; the mutex must be shared
// across instances. Each funding call internally waits for the transaction to
// mine, so the nonce is always monotonically incremented before the next caller
// acquires the lock.
var anvilFundingMu sync.Mutex

// NewFundedAccount generates a fresh secp256k1 key and funds it from
// AnvilAccount0. eth is the amount of ETH to transfer (whole units, e.g. 1 for
// 1 ETH). tokens is the amount of the ERC-20 at tokenAddr to transfer (whole
// units, 18-decimal assumed). Pass 0 for either to skip that transfer.
//
// The method is safe to call from parallel tests. Internally it holds a
// package-level mutex while touching AnvilAccount0's nonce, so callers never
// race each other. The returned account is fully funded before the method
// returns.
func (d *DSL) NewFundedAccount(ctx context.Context, t *testing.T, eth int, tokenAddr common.Address, tokens int) stack.Account {
t.Helper()
if d.anvil == nil {
t.Fatal("NewFundedAccount not available: Anvil shim not initialized")
return stack.Account{}
}
key, err := crypto.GenerateKey()
if err != nil {
t.Fatalf("NewFundedAccount: generate key: %v", err)
}
acc := stack.Account{
Address: crypto.PubkeyToAddress(key.PublicKey),
PrivateKey: hex.EncodeToString(crypto.FromECDSA(key)),
}

const (
base = 10
decimals = 18
)
exp18 := new(big.Int).Exp(big.NewInt(base), big.NewInt(decimals), nil)

anvilFundingMu.Lock()
defer anvilFundingMu.Unlock()

funder := stack.AnvilAccount0
if eth > 0 {
ethWei := new(big.Int).Mul(big.NewInt(int64(eth)), exp18)
if err := d.anvil.FundWithETH(ctx, &funder, acc.Address, ethWei); err != nil {
t.Fatalf("NewFundedAccount: fund ETH: %v", err)
}
}
if tokens > 0 {
if (tokenAddr == common.Address{}) {
t.Fatalf("NewFundedAccount: tokens > 0 but tokenAddr is zero address")
}
tokenWei := new(big.Int).Mul(big.NewInt(int64(tokens)), exp18)
if err := d.anvil.TransferERC20(ctx, &funder, acc.Address, tokenAddr, tokenWei); err != nil {
t.Fatalf("NewFundedAccount: fund ERC20: %v", err)
}
}
return acc
}
Loading
Loading