diff --git a/.golangci.yml b/.golangci.yml index feca6c8af..9573de8fb 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -41,6 +41,8 @@ linters: gosec: excludes: - G115 + - G204 # subprocess with tainted args — controlled via config/CLI + - G602 # slice OOB — false positive in taint analysis revive: rules: - name: exported @@ -62,6 +64,12 @@ linters: - linters: - dupl path: etherman/contracts/contracts_(banana|elderberry)\.go + - linters: + - gosec + text: "G703" # path traversal false positive — not a valid rule ID in golangci-lint v2.4.0 + - linters: + - gosec + text: "G118" # context cancel false positive — not a valid rule ID in golangci-lint v2.4.0 paths: - tests - third_party$ diff --git a/Makefile b/Makefile index b6e0a796b..4fd9fbf84 100644 --- a/Makefile +++ b/Makefile @@ -77,14 +77,38 @@ build-aggkit: ## Builds aggkit binary GIN_MODE=release $(GOENVVARS) go build -ldflags "all=$(LDFLAGS)" -o $(GOBIN)/$(GOBINARY) $(GOCMD) .PHONY: build-tools -build-tools: $(GOBIN)/aggsender_find_imported_bridge $(GOBIN)/remove_ger ## Builds the tools +build-tools: $(GOBIN)/aggsender_find_imported_bridge $(GOBIN)/remove_ger $(GOBIN)/exit_certificate $(GOBIN)/exit_certificate_claimer ## Builds the tools -$(GOBIN)/aggsender_find_imported_bridge: ## Build aggsender_find_imported_bridge tool + +.PHONY: build-aggsender_find_imported_bridge +build-aggsender_find_imported_bridge: $(GOBIN)/aggsender_find_imported_bridge ## Build aggsender_find_imported_bridge tool + +.PHONY: build-remove_ger +build-remove_ger: $(GOBIN)/remove_ger ## Build remove_ger tool + +.PHONY: build-exit_certificate +build-exit_certificate: $(GOBIN)/exit_certificate ## Build exit_certificate tool + +.PHONY: build-exit_certificate_claimer +build-exit_certificate_claimer: $(GOBIN)/exit_certificate_claimer ## Build exit_certificate_claimer backend tool + +.PHONY: $(GOBIN)/aggsender_find_imported_bridge +$(GOBIN)/aggsender_find_imported_bridge: $(GOENVVARS) go build -o $(GOBIN)/aggsender_find_imported_bridge ./tools/aggsender_find_imported_bridge -$(GOBIN)/remove_ger: ## Build remove_ger tool + +.PHONY: $(GOBIN)/remove_ger +$(GOBIN)/remove_ger: $(GOENVVARS) go build -ldflags "all=$(LDFLAGS)" -o $(GOBIN)/remove_ger ./tools/remove_ger/cmd +.PHONY: $(GOBIN)/exit_certificate +$(GOBIN)/exit_certificate: + $(GOENVVARS) go build -o $(GOBIN)/exit_certificate ./tools/exit_certificate/cmd + +.PHONY: $(GOBIN)/exit_certificate_claimer +$(GOBIN)/exit_certificate_claimer: + $(GOENVVARS) go build -o $(GOBIN)/exit_certificate_claimer ./tools/exit_certificate_claimer/service/cmd + .PHONY: build-docker build-docker: ## Builds a docker image with the aggkit binary docker build -t aggkit:local -f ./Dockerfile . diff --git a/go.mod b/go.mod index f31ee8d43..ca137a3dd 100644 --- a/go.mod +++ b/go.mod @@ -12,20 +12,20 @@ require ( github.com/0xPolygon/cdk-rpc v0.0.0-20250213125803-179882ad6229 github.com/0xPolygon/zkevm-ethtx-manager v0.2.18 github.com/agglayer/go_signer v0.0.7 - github.com/ethereum/go-ethereum v1.17.3 + github.com/ethereum/go-ethereum v1.17.2 github.com/gin-gonic/gin v1.12.0 github.com/golang-collections/collections v0.0.0-20130729185459-604e922904d3 github.com/hermeznetwork/tracerr v0.3.2 - github.com/invopop/jsonschema v0.14.0 + github.com/invopop/jsonschema v0.13.0 github.com/jellydator/ttlcache/v3 v3.4.0 github.com/jmoiron/sqlx v1.4.0 github.com/knadh/koanf/parsers/json v1.0.0 github.com/knadh/koanf/parsers/toml v0.1.0 github.com/knadh/koanf/providers/rawbytes v1.0.0 github.com/knadh/koanf/v2 v2.3.4 - github.com/mattn/go-sqlite3 v1.14.44 + github.com/mattn/go-sqlite3 v1.14.42 github.com/mitchellh/mapstructure v1.5.0 - github.com/pelletier/go-toml/v2 v2.3.1 + github.com/pelletier/go-toml/v2 v2.3.0 github.com/prometheus/client_golang v1.23.2 github.com/prometheus/client_model v0.6.2 github.com/rubenv/sql-migrate v1.8.1 @@ -37,10 +37,10 @@ require ( github.com/swaggo/swag v1.16.6 github.com/urfave/cli/v2 v2.27.7 github.com/valyala/fasttemplate v1.2.2 - go.uber.org/zap v1.28.0 + go.uber.org/zap v1.27.1 golang.org/x/sync v0.20.0 - google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 - google.golang.org/grpc v1.81.1 + google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 + google.golang.org/grpc v1.80.0 google.golang.org/protobuf v1.36.11 ) @@ -162,7 +162,6 @@ require ( github.com/modern-go/reflect2 v1.0.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/onsi/gomega v1.27.10 // indirect - github.com/pb33f/ordered-map/v2 v2.3.1 // indirect github.com/pelletier/go-toml v1.9.5 // indirect github.com/pion/dtls/v2 v2.2.12 // indirect github.com/pion/logging v0.2.2 // indirect @@ -193,6 +192,7 @@ require ( github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect github.com/wlynxg/anet v0.0.4 // indirect github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect github.com/yusufpapurcu/wmi v1.2.3 // indirect @@ -200,30 +200,29 @@ require ( go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 // indirect - go.opentelemetry.io/otel v1.43.0 // indirect - go.opentelemetry.io/otel/metric v1.43.0 // indirect - go.opentelemetry.io/otel/trace v1.43.0 // indirect + go.opentelemetry.io/otel v1.40.0 // indirect + go.opentelemetry.io/otel/metric v1.40.0 // indirect + go.opentelemetry.io/otel/trace v1.40.0 // indirect go.uber.org/multierr v1.10.0 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - go.yaml.in/yaml/v4 v4.0.0-rc.2 // indirect golang.org/x/arch v0.22.0 // indirect - golang.org/x/crypto v0.48.0 // indirect + golang.org/x/crypto v0.51.0 // indirect golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect golang.org/x/lint v0.0.0-20200302205851-738671d3881b // indirect - golang.org/x/mod v0.32.0 // indirect - golang.org/x/net v0.51.0 // indirect - golang.org/x/oauth2 v0.36.0 // indirect - golang.org/x/sys v0.42.0 // indirect - golang.org/x/telemetry v0.0.0-20260109210033-bd525da824e2 // indirect - golang.org/x/text v0.34.0 // indirect + golang.org/x/mod v0.35.0 // indirect + golang.org/x/net v0.55.0 // indirect + golang.org/x/oauth2 v0.34.0 // indirect + golang.org/x/sys v0.45.0 // indirect + golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa // indirect + golang.org/x/text v0.37.0 // indirect golang.org/x/time v0.12.0 // indirect - golang.org/x/tools v0.41.0 // indirect + golang.org/x/tools v0.44.0 // indirect golang.org/x/tools/go/expect v0.1.1-deprecated // indirect golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated // indirect google.golang.org/api v0.215.0 // indirect google.golang.org/genproto v0.0.0-20241118233622-e639e219e697 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260120221211-b8f7ae30c516 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index 0f9ab4209..07a48df11 100644 --- a/go.sum +++ b/go.sum @@ -102,8 +102,8 @@ github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMn github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= -github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik= -github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4= +github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w= +github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI= github.com/cockroachdb/datadriven v1.0.3-0.20230413201302-be42291fc80f h1:otljaYPt5hWxV3MUfO5dFPFiOXg9CyG5/kCfayTqsJ4= github.com/cockroachdb/datadriven v1.0.3-0.20230413201302-be42291fc80f/go.mod h1:a9RdTaap04u637JoCzcUoIcDmvwSUtcUFtT/C3kJlTU= github.com/cockroachdb/errors v1.11.3 h1:5bA+k2Y6r+oz/6Z/RFlNeVCesGARKuC6YymtcDrbC/I= @@ -144,18 +144,18 @@ github.com/didip/tollbooth/v6 v6.1.2/go.mod h1:xjcse6CTHCLuOkzsWrEgdy9WPJFv+p/x6 github.com/emicklei/dot v1.6.2 h1:08GN+DD79cy/tzN6uLCT84+2Wk9u+wvqP+Hkx/dIR8A= github.com/emicklei/dot v1.6.2/go.mod h1:DeV7GvQtIw4h2u73RKBkkFdvVAz0D9fzeJrgPW6gy/s= github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= -github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ= -github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A= -github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds= -github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0= +github.com/envoyproxy/go-control-plane/envoy v1.36.0 h1:yg/JjO5E7ubRyKX3m07GF3reDNEnfOboJ0QySbH736g= +github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98= +github.com/envoyproxy/protoc-gen-validate v1.3.0 h1:TvGH1wof4H33rezVKWSpqKz5NXWg5VPuZ0uONDT6eb4= +github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA= github.com/ethereum-optimism/infra/op-signer v1.4.1 h1:vLNPTqbSZH85KItWDuCoRsNtoCHdHZwBS3jl3FjNMKE= github.com/ethereum-optimism/infra/op-signer v1.4.1/go.mod h1:znWwvyDM9lYCQUUjMzQAF8+ysPa418ZzopUr9tdPJWI= github.com/ethereum/c-kzg-4844/v2 v2.1.6 h1:xQymkKCT5E2Jiaoqf3v4wsNgjZLY0lRSkZn27fRjSls= github.com/ethereum/c-kzg-4844/v2 v2.1.6/go.mod h1:8HMkUZ5JRv4hpw/XUrYWSQNAUzhHMg2UDb/U+5m+XNw= github.com/ethereum/go-bigmodexpfix v0.0.0-20250911101455-f9e208c548ab h1:rvv6MJhy07IMfEKuARQ9TKojGqLVNxQajaXEp/BoqSk= github.com/ethereum/go-bigmodexpfix v0.0.0-20250911101455-f9e208c548ab/go.mod h1:IuLm4IsPipXKF7CW5Lzf68PIbZ5yl7FFd74l/E0o9A8= -github.com/ethereum/go-ethereum v1.17.3 h1:Ev/sQHH+UdKZHWjuVzhu2pxhi/sXaPZl23Q+Q5LDd4Q= -github.com/ethereum/go-ethereum v1.17.3/go.mod h1:f2EhRwqewIZkGoQekywI2Y2RZAMTSavLNkD9qItFy1A= +github.com/ethereum/go-ethereum v1.17.2 h1:ag6geu0kn8Hv5FLKTpH+Hm2DHD+iuFtuqKxEuwUsDOI= +github.com/ethereum/go-ethereum v1.17.2/go.mod h1:KHcRXfGOUfUmKg51IhQ0IowiqZ6PqZf08CMtk0g5K1o= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/ferranbt/fastssz v0.1.4 h1:OCDB+dYDEQDvAgtAGnTSidK1Pe2tW3nFV40XyMkTeDY= @@ -298,8 +298,8 @@ github.com/influxdata/influxdb1-client v0.0.0-20220302092344-a9ab5670611c h1:qSH github.com/influxdata/influxdb1-client v0.0.0-20220302092344-a9ab5670611c/go.mod h1:qj24IKcXYK6Iy9ceXlo3Tc+vtHo9lIhSX5JddghvEPo= github.com/influxdata/line-protocol v0.0.0-20200327222509-2487e7298839 h1:W9WBk7wlPfJLvMCdtV4zPulc4uCPrlywQOmbFOhgQNU= github.com/influxdata/line-protocol v0.0.0-20200327222509-2487e7298839/go.mod h1:xaLFMmpvUxqXtVkUJfg9QmT88cDaCJ3ZKgdZ78oO8Qo= -github.com/invopop/jsonschema v0.14.0 h1:MHQqLhvpNUZfw+hM3AZDYK7jxO8FZoQeQM77g8iyZjg= -github.com/invopop/jsonschema v0.14.0/go.mod h1:ygm6C2EaVNMBDPpaPlnOA2pFAxBnxGjFlMZABxm9n2I= +github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= +github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= github.com/jackpal/go-nat-pmp v1.0.2 h1:KzKSgb7qkJvOUTqYl9/Hg/me3pWgBmERKrTGD7BdWus= github.com/jackpal/go-nat-pmp v1.0.2/go.mod h1:QPH045xvCAeXUZOxsnwmrtiCoxIr9eob+4orBN1SBKc= github.com/jellydator/ttlcache/v3 v3.4.0 h1:YS4P125qQS0tNhtL6aeYkheEaB/m8HCqdMMP4mnWdTY= @@ -356,8 +356,8 @@ github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6T github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-sqlite3 v1.14.7/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= -github.com/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8= -github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= +github.com/mattn/go-sqlite3 v1.14.42 h1:MigqEP4ZmHw3aIdIT7T+9TLa90Z6smwcthx+Azv4Cgo= +github.com/mattn/go-sqlite3 v1.14.42/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= github.com/minio/sha256-simd v1.0.0 h1:v1ta+49hkWZyvaKwrQB8elexRqm6Y0aMLjCNsrYxo6g= github.com/minio/sha256-simd v1.0.0/go.mod h1:OuYzVNI5vcoYIAmbIvHPl3N3jUzVedXbKy5RFepssQM= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= @@ -394,12 +394,10 @@ github.com/onsi/gomega v1.27.10 h1:naR28SdDFlqrG6kScpT8VWpu1xWY5nJRCF3XaYyBjhI= github.com/onsi/gomega v1.27.10/go.mod h1:RsS8tutOdbdgzbPtzzATp12yT7kM5I5aElG3evPbQ0M= github.com/opentracing/opentracing-go v1.1.0 h1:pWlfV3Bxv7k65HYwkikxat0+s3pV4bsqf19k25Ur8rU= github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o= -github.com/pb33f/ordered-map/v2 v2.3.1 h1:5319HDO0aw4DA4gzi+zv4FXU9UlSs3xGZ40wcP1nBjY= -github.com/pb33f/ordered-map/v2 v2.3.1/go.mod h1:qxFQgd0PkVUtOMCkTapqotNgzRhMPL7VvaHKbd1HnmQ= github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= -github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc= -github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM= +github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/peterh/liner v1.1.1-0.20190123174540-a2c9a5303de7 h1:oYW+YCJ1pachXTQmzR3rNLYGGz4g/UgFcjb28p/viDM= github.com/peterh/liner v1.1.1-0.20190123174540-a2c9a5303de7/go.mod h1:CRroGNssyjTd/qIG2FyxByd2S8JEAZXBl4qUrZf8GS0= github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4= @@ -512,6 +510,8 @@ github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6Kllzaw github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= +github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= +github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= github.com/wlynxg/anet v0.0.3/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= github.com/wlynxg/anet v0.0.4 h1:0de1OFQxnNqAu+x2FAKKCVIrnfGKQbs7FQz++tB0+Uw= github.com/wlynxg/anet v0.0.4/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= @@ -533,30 +533,28 @@ go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.5 go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0/go.mod h1:B9yO6b04uB80CzjedvewuqDhxJxi11s7/GtiGa8bAjI= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8= -go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= -go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= -go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= -go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= -go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= -go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= -go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= -go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= -go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= -go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= +go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= +go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= +go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= +go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= +go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE= +go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= +go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= +go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= +go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= -go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= +go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= +go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -go.yaml.in/yaml/v4 v4.0.0-rc.2 h1:/FrI8D64VSr4HtGIlUtlFMGsm7H7pWTbj6vOLVZcA6s= -go.yaml.in/yaml/v4 v4.0.0-rc.2/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= @@ -567,8 +565,8 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE= golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= -golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= -golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= +golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= golang.org/x/exp v0.0.0-20190221220918-438050ddec5e/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= @@ -583,8 +581,8 @@ golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= -golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= +golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= +golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= @@ -604,11 +602,11 @@ golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI= golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= -golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= -golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= -golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= -golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= +golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -647,10 +645,10 @@ golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/telemetry v0.0.0-20260109210033-bd525da824e2 h1:O1cMQHRfwNpDfDJerqRoE2oD+AFlyid87D40L/OkkJo= -golang.org/x/telemetry v0.0.0-20260109210033-bd525da824e2/go.mod h1:b7fPSJ0pKZ3ccUh8gnTONJxhn3c/PS6tyzQvyqw4iA8= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa h1:efT73AJZfAAUV7SOip6pWGkwJDzIGiKBZGVzHYa+ve4= +golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa/go.mod h1:kHjTxDEnAu6/Nl9lDkzjWpR+bmKfxeiRuSDlsMb70gE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= @@ -666,8 +664,8 @@ golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= @@ -683,8 +681,8 @@ golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4f golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= -golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= +golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= +golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM= golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM= @@ -702,14 +700,14 @@ google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9Ywl google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20241118233622-e639e219e697 h1:ToEetK57OidYuqD4Q5w+vfEnPvPpuTwedCNVohYJfNk= google.golang.org/genproto v0.0.0-20241118233622-e639e219e697/go.mod h1:JJrvXBWRZaFMxBufik1a4RpFw4HhgVtBBWQeQgUj2cc= -google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 h1:tu/dtnW1o3wfaxCOjSLn5IRX4YDcJrtlpzYkhHhGaC4= -google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171/go.mod h1:M5krXqk4GhBKvB596udGL3UyjL4I1+cTbK0orROM9ng= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto/googleapis/api v0.0.0-20260120221211-b8f7ae30c516 h1:vmC/ws+pLzWjj/gzApyoZuSVrDtF1aod4u/+bbj8hgM= +google.golang.org/genproto/googleapis/api v0.0.0-20260120221211-b8f7ae30c516/go.mod h1:p3MLuOwURrGBRoEyFHBT3GjUwaCQVKeNqqWxlcISGdw= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 h1:sNrWoksmOyF5bvJUcnmbeAmQi8baNhqg5IWaI3llQqU= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= google.golang.org/grpc v1.16.0/go.mod h1:0JHn/cJsOMiMfNA9+DeHDlAU7KAAB5GDlYFpa9MZMio= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= -google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= -google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= +google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= +google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= diff --git a/l1infotreesync/migrations/migrations.go b/l1infotreesync/migrations/migrations.go index 606907a72..9199e37f5 100644 --- a/l1infotreesync/migrations/migrations.go +++ b/l1infotreesync/migrations/migrations.go @@ -26,24 +26,13 @@ var mig003 string var mig004 string func RunMigrations(dbPath string) error { - migrations := []types.Migration{ - { - ID: "l1infotreesync0001", - SQL: mig001, - }, - { - ID: "l1infotreesync0002", - SQL: mig002, - }, - { - ID: "l1infotreesync0003", - SQL: mig003, - }, - { - ID: "l1infotreesync0004", - SQL: mig004, - }, - } + migrations := make([]types.Migration, 0, 4+2*len(treeMigrations.Migrations)) //nolint:mnd + migrations = append(migrations, + types.Migration{ID: "l1infotreesync0001", SQL: mig001}, + types.Migration{ID: "l1infotreesync0002", SQL: mig002}, + types.Migration{ID: "l1infotreesync0003", SQL: mig003}, + types.Migration{ID: "l1infotreesync0004", SQL: mig004}, + ) for _, tm := range treeMigrations.Migrations { migrations = append(migrations, types.Migration{ ID: tm.ID, diff --git a/sonar-project.properties b/sonar-project.properties index 93fc32949..e1a15ac9a 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -7,7 +7,7 @@ sonar.projectName=aggkit sonar.organization=agglayer sonar.sources=. -sonar.exclusions=**/test/**,**/vendor/**,**/mocks/**,**/build/**,**/target/**,**/proto/include/**,**/*.pb.go,**/docs/**,**/*.sql,**/mocks_*/*,scripts/**,**/mock_*.go,**/cmd/**,tools/** +sonar.exclusions=**/test/**,**/vendor/**,**/mocks/**,**/build/**,**/target/**,**/proto/include/**,**/*.pb.go,**/docs/**,**/*.sql,**/mocks_*/*,scripts/**,**/mock_*.go,**/cmd/** sonar.tests=. sonar.test.inclusions=**/*_test.go diff --git a/sync/evmdownloader_test.go b/sync/evmdownloader_test.go index f06a867e7..17ec94eaa 100644 --- a/sync/evmdownloader_test.go +++ b/sync/evmdownloader_test.go @@ -47,7 +47,7 @@ func TestGetEventsByBlockRange(t *testing.T) { setupMocks func(*aggkittypesmocks.MultiDownloader) contextCancelled bool } - testCases := []testCase{} + testCases := make([]testCase, 0, 9) ctx := context.Background() d, clientMock := NewTestDownloader(t, time.Millisecond*100) diff --git a/tools/exit_certificate/.gitignore b/tools/exit_certificate/.gitignore new file mode 100644 index 000000000..913f261ef --- /dev/null +++ b/tools/exit_certificate/.gitignore @@ -0,0 +1,5 @@ +parameters.json +parameters.toml +output/ +*.json.tmp +exit-certificate \ No newline at end of file diff --git a/tools/exit_certificate/CLAUDE.md b/tools/exit_certificate/CLAUDE.md new file mode 100644 index 000000000..686814b6c --- /dev/null +++ b/tools/exit_certificate/CLAUDE.md @@ -0,0 +1,419 @@ +# exit_certificate tool — spec for AI assistants + +## Purpose + +Standalone CLI tool that generates an agglayer `Certificate` for an L2 chain exiting the Agglayer +ecosystem. It scans L2 state from genesis to a target block, computes all balances, and produces +a certificate containing `BridgeExit` entries that transfer every balance (ETH + wrapped tokens) +to the destination network. + +## Package layout + +```text +tools/exit_certificate/ +├── cmd/main.go — CLI entry point (urfave/cli/v2) +├── run.go — pipeline orchestration (Run, runAll, runSingleStep) +├── config.go — Config, Options, LoadConfig, LBT file parsing +├── types.go — all domain types (StepXResult, LBTEntry, EOABalance, …) +├── rpc.go — raw JSON-RPC helpers (singleRPC, concurrentBatchRPC, …) +├── worker.go — generic worker pool (runWorkerPool) +├── hex.go — hex/uint64 conversion utilities +├── step_0.go — LBT generation +├── step_a.go — address collection via debug_traceTransaction +├── step_b.go — EOA classification + balance fetching +├── step_c.go — SC-locked value computation +├── step_d.go — build agglayer Certificate +├── step_e.go — unclaimed L1→L2 deposits +├── step_f.go — agglayer token balance verification +├── step_g1.go — resolve shadow-fork block (real-L2 bridgesync pre-sync) +├── step_g2.go — NewLocalExitRoot computation (Step G2) +├── step_h.go — fetch PreviousLocalExitRoot from agglayer +├── step_i.go — assemble final certificate (LER, prev LER, L1InfoTreeLeafCount) +├── step_check.go — prerequisite checks (Anvil, L1 RPC, network type, threshold, gas token) +├── step_sign.go — ECDSA certificate signing +├── step_submit.go — send certificate to agglayer via gRPC +├── step_wait.go — poll agglayer until certificate is settled or in error +└── parameters.json.example +``` + +## Pipeline + +Full pipeline order (`runAll`): **CHECK → 0 → A → B → C → D → E → F → G → H → I → SIGN** + +Post-submission steps (explicit only, not part of `runAll`): **SUBMIT → WAIT** + +Each step reads its inputs from disk (output dir) and writes its outputs to disk. The +`runAll` path passes data in memory directly; `runSingleStep` always loads from disk. + +### Step CHECK — Verify prerequisites + +Runs automatically as the first step of the full pipeline, and can also be triggered individually with `--step check`. + +All checks run regardless of individual failures. A combined error lists every failed check. + +1. **Anvil installed** — `anvil` must be in `$PATH` (required by Step G2 only when `options.verifyNewLocalExitRootUsingShadowFork=true`). Fails with a clear error pointing to [getfoundry.sh](https://getfoundry.sh) if missing. +2. **L1 RPC reachable** — dials `l1RpcUrl` and calls `eth_blockNumber`. Fails if not set or unreachable. +3. **L2 network ID matches bridge** — calls `NetworkID()` on the L2 bridge contract and verifies it matches `l2NetworkId` in config. +4. **`sovereignRollupAddr` is set** — required; fails if zero address. +5. **Network type is PP** — queries `AGGCHAINTYPE()` on the `aggchainbase` contract at `sovereignRollupAddr` on L1. Fails if FEP. Only runs if checks 2 and 4 passed. +6. **Threshold is 1** — queries `Threshold()` and `GetAggchainSignerInfos()`. Fails if threshold > 1. Also verifies the bridge address on the contract matches config. Only runs if checks 2 and 4 passed. +7. **No custom gas token** — calls `gasTokenAddress()`/`gasTokenNetwork()` on the L2 bridge. Fails if a non-zero gas token is set (not supported). + +- **Output:** `step-check-result.json` (`StepCheckResult`) + +### Step 0 — Generate LBT + +- **Trigger:** always runs as part of the full pipeline. +- **Does:** first resolves `targetBlock` (finality keyword, optional offset, or concrete number) to a `uint64` via an RPC call when needed; then scans L2 bridge `NewWrappedToken` events, fetches `totalSupply` per token at the resolved block, computes unlocked native balance. +- **Output:** `step-0-l2_target_block.json` (resolved block number as `uint64`), `step-0-lbt.json` (`[]LBTEntry`) + +### Step A — Collect addresses + +- **RPC:** `eth_getBlockByNumber` (headers, `false`) → tx hashes; then `debug_traceTransaction` with `prestateTracer`+`diffMode` per hash. +- **Output:** `step-a-addresses.json` (`[]common.Address`), `step-a-failed-traces.json` (`[]common.Hash`) +- **Option:** `ignoreOnTraceError=true` skips failed traces instead of aborting. + +### Step B — EOA balance checking + ERC-20 detection + +Three sub-steps: B1, B2, B3. Running `--step b` executes all three. + +#### Step B1 — EOA classification and balance fetching + +1. `eth_getCode` → classify each address as EOA or contract +2. `eth_getBalance` for all EOAs at `targetBlock` +3. `balanceOf(address)` per wrapped token × per EOA (token list from LBT) + +- **Output:** `step-b-eoa-balances.json` (`[]EOABalance`), `step-b-accumulated.json` (`[]AccumulatedBalance`), `step-b-contract-addresses.json` (`[]common.Address`) + +#### Step B2 — ERC-20 detection in contracts + +Probes each contract address with `totalSupply()` / `balanceOf(address(0))` to confirm the ERC-20 interface. For each detected ERC-20, calls `balanceOf(contractAddr)` on every tracked wrapped token and `eth_getBalance` to find which tracked tokens it holds. + +- Holds ≥ 1 tracked token → `DetectedERC20` (relevant) +- Holds none → `DiscardedERC20` (irrelevant) + +- **Output:** `step-b2-detected-erc20s.json` (`[]DetectedERC20`), `step-b2-discarded-erc20s.json` (`[]DiscardedERC20`) + +#### Step B3 — Extra ERC-20 holder decomposition + +Iterates over `options.extraErc20Contracts`. For each address: + +- If Step B2 already populated `Holders` for it, copies those holders and marks `AlreadyFromB2=true` — no RPC call. +- Otherwise, calls `fetchTokenBalances` (one RPC batch of `balanceOf` for every EOA from Step A). + +Skipped automatically when `options.extraErc20Contracts` is empty. + +- **Output:** `step-b3-erc20-holders.json` (`[]ERC20HolderBreakdown`) + +### Step C — SC-locked value + +- **Formula:** `SC_locked = LBT_totalSupply − accumulated_EOA_balances` per token. +- **Output:** `step-c-sc-locked-values.json` (`[]SCLockedValue`) + +### Step D — Build certificate + +Creates the `*agglayertypes.Certificate` with `BridgeExit` entries: + +- One per (EOA, token) pair with non-zero balance → destination is the EOA address on `destinationNetwork`. +- One per token with SC-locked value > 0 → destination is `exitAddress` on `destinationNetwork`. + +- **Output:** `step-d-exit-certificate.json` + +### Step E — Unclaimed L1→L2 deposits + +- **Requires:** `l1RpcUrl` (skipped otherwise). +- Scans L1 `BridgeEvent` events targeting L2 network, checks each deposit against `isClaimed` on L2 bridge. +- Splits unclaimed deposits by leaf type: **assets** (`leaf_type=0`) are added to the certificate as `bridge_exits` + `imported_bridge_exits` (with `claim_data: null`); **messages** (`leaf_type=1`) are excluded from the certificate and saved separately. +- **Bridge service cross-check:** when `options.bridgeServiceURL` is set, compares the detected unclaimed asset set against the bridge service's pending-bridges and errors on any discrepancy. Controlled by `options.bridgeServiceType` (`"aggkit"` → `GET /bridge/v1/bridges`; `"zkevm"` → `GET /pending-bridges`). +- **Output:** `step-e-unclaimed-bridges.json` (`[]L1Deposit`), `step-e-unclaimed-messages.json` (`[]L1Deposit`, always written), `step-e-exit-certificate.json` + +### Step F — Agglayer balance verification + +- **Mode:** `options.useAgglayerAdminToStepFCheck` (default `true`) selects the comparison source: + - **`true` (agglayer mode):** calls `admin_getTokenBalance` on the agglayer admin RPC and performs a **three-way comparison** per token: `LBT (Step 0) == agglayer == certificate sum`. Requires `agglayerAdminURL` (errors without it). When LBT is unavailable, falls back to two-way (certificate vs agglayer). + - **`false` (offline mode, `runStepFOfflineLBT`):** **no agglayer query** — performs a two-way **LBT (Step 0) vs certificate sum** comparison per token. No `agglayerAdminURL` needed. When no LBT data is available there is nothing to compare and the step is skipped with a benign all-match result. `AgglayerAmount` is empty in the checks and `step-f-token-balances.json` is not written. +- Each token is logged with ✅ or ❌. The shared `finalizeStepFResult` applies the `ignoreBalanceMismatch` policy in both modes. +- **On mismatch:** aborts the pipeline with an error by default. +- **`ignoreBalanceMismatch=true`:** suppresses the error and produces `step-f-capped-certificate.json`, where each mismatched token's bridge exits are proportionally scaled down to `min(agglayer, lbt)`. The pipeline (and `runSingleG`) automatically uses this capped certificate for subsequent steps. +- `buildCapMap` / `capBridgeExits` are the internal helpers for computing and applying the caps. Proportional scaling preserves the exact capped total by adding any integer-division remainder to the last exit of each group. +- **Output:** `step-f-token-balances.json`, `step-f-checks.json` (`[]TokenBalanceCheck`), `step-f-capped-certificate.json` *(only when `ignoreBalanceMismatch=true` and mismatches exist)* + +### Step G — Compute NewLocalExitRoot (shadow-fork) + +Two sub-steps: G1, G2. Running `--step g` executes both; `g1`/`g2` run them individually, and `g` +expands to `g1,g2` in ranges (e.g. `f-g` → `f,g1,g2`). + +#### Step G1 — Sync the L2 bridge history and resolve the shadow-fork block + +**Persists** every L2 bridge from genesis up to `targetBlock` using the **lite bridge syncer** +(`tools/exit_certificate/bridgesyncerlite`), reading `BridgeEvent` logs from the **real L2** +(`l2RpcUrl`) in parallel into the DB at `output/step-g1-l2bridgesyncerlite.sqlite`. It does **not** +build the exit tree here — that is deferred to Step G2, which assembles the whole tree once from the +full set (genesis→fork plus replayed). The shadow-fork block is exactly the resolved `targetBlock` +(the lite syncer fetches that range), so Anvil forks there aligned to the contract's state at that +block. Running the full-history scan against the *fast* real L2 is the point of the G1/G2 split: G2 +never re-scans the chain. + +The lite syncer aborts if the chain emitted any event that would invalidate a BridgeEvent-only +reconstruction (`SetSovereignTokenAddress`, `MigrateLegacyToken`, +`RemoveLegacySovereignTokenAddress`, `BackwardLET`, `ForwardLET`) — unless +`options.ignoreUnsupportedL2Events=true`, which downgrades the abort to a warning and skips the event +(the resulting LER may then be incorrect). `NewWrappedToken` is ignored (it is neither indexed nor +processed). + +- **Output:** `step-g1-shadow-fork-block.json` (`StepG1Result`: `shadowForkBlock`) and the lite DB + `output/step-g1-l2bridgesyncerlite.sqlite`. + +#### Step G2 — Compute NewLocalExitRoot + +> **Input priority (single-step mode):** loads the shadow-fork block from `step-g1-shadow-fork-block.json` (run G1 first); uses `step-f-capped-certificate.json` if it exists (logged with ⚠️), otherwise falls back to `step-e-exit-certificate.json`. In `runAll` the in-memory certificate already reflects any capping done by Step F. + +Step G2 has two modes, selected by `options.verifyNewLocalExitRootUsingShadowFork` (default `true`, +i.e. the shadow-fork mode below). + +##### Off-chain lite exit tree (no Anvil) — `options.verifyNewLocalExitRootUsingShadowFork=false` + +`runStepG2LiteOnly` → `buildLiteTreeFromCertificate` (`step_g_events.go`): **copies** the lite DB +Step G1 populated (`output/step-g1-l2bridgesyncerlite.sqlite` → `output/step-g-l2bridgesyncerlite.sqlite`, +so G1's DB stays intact), converts the certificate's `bridge_exits` into lite leaves **in their +given order** — continuing the deposit counts after the genesis→fork bridges — and **builds the +whole exit tree once**. The tree root is the `NewLocalExitRoot`. No reorder, no Anvil. + +Each leaf is encoded as the bridge contract would: a native exit (nil/zero token info, or the gas +token) takes the gas token as origin; an ERC-20 exit takes its `TokenInfo` origin. **Metadata is +taken verbatim from each `BridgeExit`** (empty unless a prior step populated it). This is the one +value not verified against the chain in this mode — if an exit needs non-empty metadata (e.g. an +L2-native token bridged out, where the contract encodes name/symbol/decimals), the off-chain LER +would diverge from the real one. Use the shadow-fork mode to verify. + +##### Anvil shadow-fork (default — `options.verifyNewLocalExitRootUsingShadowFork=true`) + +`runStepG2ShadowFork` drives the **actual** bridge contract on a fork, eliminating any leaf-encoding +divergence risk, and verifies the off-chain reconstruction against it. + +1. **Fork L2 at the Step G1 block** — spin up an Anvil instance (`anvil --fork-url + --fork-block-number --block-time --disable-block-gas-limit + --auto-impersonate --no-rate-limit`). Anvil is a required external dependency for this mode. + **Interval mining** (`--block-time`) is used instead of auto-mine: with auto-mine each `bridgeAsset` + would produce its own block, so a mainnet replay (hundreds of thousands of exits) accumulates that + many blocks and Anvil degrades until receipt polling times out. Anvil instead mines a block every + interval, batching all pending txs into it; `--disable-block-gas-limit` lets one block hold every + pending tx. `--auto-impersonate` drops the per-tx `anvil_impersonateAccount` calls (balance is set + once per sender). `--no-rate-limit` disables Anvil's internal ~330 CUPS throttle to the fork + backend, which otherwise caps cold-state fetches to a few exits/s regardless of concurrency. + + > **Fork backend is the bottleneck.** Replaying against a *remote* `l2RpcUrl` means every cold + > storage slot is a network round-trip; throughput is bound by the upstream RPC's latency and rate + > limits. Transient fork errors are retried (`isTransientForkError`, + > `--retries`/`--fork-retry-backoff`). For a large replay, fork against a **local archive node**. +2. **Fund the senders** — Anvil runs with `--auto-impersonate`, so any account can send txs; each + sender's ETH balance is set once with `anvil_setBalance`. For ERC-20 exits, the sender's token + balance is patched to `MaxUint256` via storage and a single `approve(bridge, MaxUint256)` is sent + per (sender, token). +3. **Replay bridge exits via a send/collect pipeline** — for each `BridgeExit`, send `bridgeAsset` + (`forceUpdateGlobalExitRoot=false`, empty `permitData`). `replayBridgeExits` does **not** wait for + each tx's receipt before sending the next; sender workers (one per sender group, + `concurrency = options.concurrencyLimit`) fire all of a sender's txs onto a bounded channel + (`replayInFlightWindow`) while collector workers pull them and fetch receipts in parallel. + **Exits are grouped by sender (`DestinationAddress`)**: same-sender txs are sent sequentially so + Anvil assigns nonces in order (approve before bridge). As each receipt is collected its + `BridgeEvent` is parsed into a `bridgesyncerlite.BridgeLeaf` — the on-chain `depositCount`, leaf + content, metadata and block position — and stored at the exit's original index + (`replayBridgeExits` returns `[]BridgeLeaf`). +4. **Read `getRoot()`** on the forked contract after every exit is replayed — the authoritative + on-chain LER, which becomes `Certificate.NewLocalExitRoot`. +5. **Reorder the certificate by deposit count** — `reorderCertificateByDepositCount` (`step_g_order.go`) + sorts the exits (and the metadata slice) by the captured `DepositCount`, aligning the certificate + with the on-chain exit-tree leaf order (agglayer rebuilds the LER by inserting `bridge_exits` in + order). The metadata also comes from the replayed leaves (the real on-chain metadata). +6. **Verify** — `buildLiteTreeWithReplayed` inserts the replayed leaves into the copied lite DB on + top of the genesis→fork bridges and builds the tree; its root **must** equal the contract's + `getRoot()`. A mismatch aborts Step G2 — except when `options.ignoreUnsupportedL2Events=true`, + where divergence is expected (the syncer skipped events the contract processed) and is only logged. + + The replay is **fail-fast on hard errors**: the first `approve`/`bridgeAsset` send failure or + on-chain revert cancels the shared context, aborts with the real error (not `context.Canceled`), + kills Anvil via `defer cleanup()`, and persists the offending exit to `step-g-failed-exit.json` + (`FailedBridgeExit`). + + A **receipt timeout** (`receiptPollTimeout`, 300s — the block did not mine in time, typically a + slow remote fork backend) is **not** fatal: the exit is deferred and retried after the + send/collect phase drains (`retryDeferredExit`). The retry loops **unbounded** until the exit + mines: each iteration **re-polls the current tx** (Anvil has usually mined its block by then) and, + only if the receipt is still absent — i.e. the tx never landed — **re-sends** the `bridgeAsset` + and polls the new hash next. Re-polling before each re-send is what keeps the tree correct: a tx + that did mine is never sent twice (which would double-count the exit's leaf). The retry exits only + on success, a **revert**, or **context cancellation** — those (and a re-send send failure) are + terminal and abort as above. A slow fork backend is never abandoned. + +**Empty bridge exits:** if the certificate has no `bridge_exits`, both modes skip straight to the +canonical `bridgesynctypes.EmptyLER` (no Anvil, no tree). + +**Reordered certificate output:** the orchestrator saves the (shadow-fork-reordered, or +default-order) certificate as `step-g-reordered-certificate.json` — written in both G2 modes. In +`runAll` the in-memory certificate flows to Step I; in single-step mode Step I **always** reads +`step-g-reordered-certificate.json` (no fallback to the capped/Step-E certificates) so the final +certificate matches the computed LER. + +- **Output (G1):** `step-g1-shadow-fork-block.json` (`StepG1Result`) and the lite syncer DB `output/step-g1-l2bridgesyncerlite.sqlite`. +- **Output (G2):** `step-g-new-local-exit-root.json` (`StepGResult`), `step-g-reordered-certificate.json`, `step-g-l2bridgesyncerlite.sqlite` (working copy of the G1 lite DB with the certificate's/replayed bridges + built tree); in shadow-fork mode also `step-g-failed-exit.json` *(only on replay failure)* + +### Step H — Fetch PreviousLocalExitRoot + +- **Requires:** `options.agglayerGrpcUrl` — uses `agglayer.NewAgglayerClient` (gRPC), same as step SUBMIT. +- Calls `interop_getNetworkInfo` with `l2NetworkId` on the agglayer JSON-RPC and reads `settled_ler`. +- If no certificate has been settled yet (`settled_ler` is null), `PreviousLocalExitRoot` is zero. +- **Output:** `step-h-previous-local-exit-root.json` (`StepHResult`) + +### Step I — Assemble final certificate + +- Reads the base certificate. In single-step mode it **always** loads + `step-g-reordered-certificate.json` (run Step G first — there is no fallback to the capped/Step-E + certificates); in `runAll` the in-memory reordered certificate flows directly from Step G. Also + reads `step-g-new-local-exit-root.json` and `step-h-previous-local-exit-root.json` (optional). +- Sets `Certificate.NewLocalExitRoot` from G and `Certificate.PrevLocalExitRoot` from H. +- **Fetches `L1InfoTreeLeafCount`** — scans L1 backwards from the latest L1 block for the most + recent `UpdateL1InfoTreeV2` event emitted by `l1GlobalExitRootAddress` and sets + `Certificate.L1InfoTreeLeafCount`. Requires `l1RpcUrl` and `l1GlobalExitRootAddress` in config. +- **Output:** `exit-certificate-final.json` (updated with both roots and leaf count) + +### Step SIGN — Sign certificate + +- **Requires:** `signerConfig.Method` (skipped in `all` mode when not set; error in single-step mode). +- Uses the same `signertypes.SignerConfig` as aggsender's `AggsenderPrivateKey`. JSON format: `{"Method": "local", "Path": "keystore.json", "Password": "pass"}` (flat, mirrors the TOML inline table). +- Fetches `eth_chainId`, loads keystore via `go_signer`, hashes the certificate with `validator.HashCertificateToSign`, signs, and wraps in `AggchainDataMultisig`. +- **Output:** `exit-certificate-signed.json` + +### Step SUBMIT — Send certificate to agglayer + +- **Not part of `runAll`** — must be triggered explicitly with `--step submit`. +- **Requires:** `options.agglayerGrpcUrl` — the agglayer gRPC endpoint; and `l1RpcUrl`. +- Loads `exit-certificate-signed.json`, creates an agglayer gRPC client, captures the **latest L1 block right before submission**, and calls `SendCertificate`. +- **Output:** `step-submit-result.json` (`StepSubmitResult` with `certificateHash` and `l1LatestBlockBeforeSubmittingCertificate`) + +### Step WAIT — Wait for certificate settlement + +- **Not part of `runAll`** — must be triggered explicitly with `--step wait`. +- **Requires:** `options.agglayerGrpcUrl` and `l1RpcUrl`. +- Reads `step-submit-result.json` (the whole `StepSubmitResult`, including `l1LatestBlockBeforeSubmittingCertificate`). +- Polls `GetCertificateHeader` by hash every 5 seconds until the submitted certificate is `Settled` (success) or `InError` (returns an error). Logs the settlement tx hash on success. +- **L1 settlement confirmation:** after the certificate settles, scans the RollupManager contract on L1 from `l1LatestBlockBeforeSubmittingCertificate` to the **finalized** block for the `VerifyBatchesTrustedAggregator` event matching the rollupID (`l2NetworkId`) and the certificate's `NewLocalExitRoot`. The RollupManager address is `rollupManagerAddress` if set, otherwise resolved on-chain from `sovereignRollupAddr.rollupManager()`. It re-resolves the finalized block and re-scans every 5 seconds until found (the settlement tx may not be finalized yet) or the context is cancelled, recording the L1 block and tx hash. **Errors** when `l1RpcUrl` is unset or when neither `rollupManagerAddress` nor `sovereignRollupAddr` is available to resolve the RollupManager. +- **L1 info tree updates:** in that same L1 block, reads the `l1GlobalExitRootAddress` contract's `UpdateL1InfoTree` and `UpdateL1InfoTreeV2` events (the global-exit-root update accompanying the settlement) and records the **last** occurrence of each (`updateL1InfoTree`, `updateL1InfoTreeV2`). Requires `l1GlobalExitRootAddress`; errors if either event is missing from the block. +- **Output:** `step-wait-result.json` (`StepWaitResult`) + +## Key types (`types.go`) + +| Type | Description | +| --- | --- | +| `LBTEntry` | LBT row: wrapped token address, origin network/token, total supply | +| `WrappedToken` | Like `LBTEntry` but without the balance field | +| `EOABalance` | Per-address: ETH balance + slice of `EOATokenBalance` | +| `AccumulatedBalance` | Sum across all EOAs for a single token | +| `SCLockedValue` | LBT total − EOA accumulated, per token | +| `L1Deposit` | Parsed `BridgeEvent` log from L1 | +| `TokenBalanceCheck` | Step F three-way comparison: `LBTAmount` (Step 0), `CertificateAmount` (sum of exits), `AgglayerAmount`. `LBTAmount` is empty when LBT data was unavailable (two-way fallback). | +| `StepG1Result` | `ShadowForkBlock` (the L2 block Step G2 forks at; the resolved targetBlock up to which G1 lite-synced the bridge history) | +| `StepGResult` | `NewLocalExitRoot` hash + bridge exit count + `BridgeExitMetadata` (per-exit BridgeEvent metadata, in deposit order) | +| `StepHResult` | `PreviousLocalExitRoot` + next certificate height from agglayer | +| `StepSubmitResult` | `certificateHash` returned by the agglayer after submission + `l1LatestBlockBeforeSubmittingCertificate` (latest L1 block captured just before the submit) | +| `StepWaitResult` | `certificateHash`, `finalStatus`, optional `settlementTxHash`, `elapsedSeconds`, the L1 `VerifyBatchesTrustedAggregator` settlement (`verifyBatchesL1Block` + `verifyBatchesTxHash`), and the last `updateL1InfoTree` / `updateL1InfoTreeV2` GER events in that block | +| `L1InfoTreeUpdate` | `UpdateL1InfoTree` event: `mainnetExitRoot`, `rollupExitRoot`, `txHash` | +| `L1InfoTreeV2Update` | `UpdateL1InfoTreeV2` event: `currentL1InfoRoot`, `leafCount`, `blockhash`, `minTimestamp`, `txHash` | + +## Config fields (`config.go`) + +**File format:** `LoadConfig` accepts both **JSON** and **TOML**, selected by file extension — a +`.toml` path is parsed as TOML, anything else (`.json` or no extension) as JSON. TOML is normalized +to JSON internally (`tomlToJSON`: decode to a map, re-encode as JSON) so both formats share one +parsing/validation path, including `signerConfig` and `agglayerClient`. Field names are identical in +both formats (camelCase keys, e.g. `l2RpcUrl`; `signerConfig` uses PascalCase `Method`/`Path`/`Password`). + +Required: `l2RpcUrl`, `l2BridgeAddress`, `exitAddress`, `targetBlock`. + +`exitAddress` is validated by `LoadConfig`: it must be present **and** must not be the zero address +(`0x00…00`) — both cases return an error. SC-locked value is bridged to this address on +`destinationNetwork`, so it must be an address whose private key the operator controls (the funds can +only be recovered by signing from it). + +`targetBlock` accepts: a finality keyword (`LatestBlock`, `FinalizedBlock`, `SafeBlock`, `PendingBlock`), an optional negative offset appended with `/` (e.g. `LatestBlock/-10`), a decimal block number (`"21000000"`), or a hex block number (`"0x1406f40"`). An empty string defaults to `LatestBlock`. The keyword is resolved to a concrete `uint64` at the start of Step 0 and written to `step-0-l2_target_block.json`; all subsequent steps (A, B, G) read that fixed number. The old lowercase aliases (`latest`, `finalized`, `safe`, `pending`) are **not** accepted — use the PascalCase keywords. + +Notable optional fields: + +- `sovereignRollupAddr` — address of the `aggchainbase` contract on L1. Required by Step CHECK (checks 4–6). Without it Step CHECK fails. +- `l1GlobalExitRootAddress` — address of `PolygonZkEVMGlobalExitRootV2` on L1. Required by Step I to fetch `L1InfoTreeLeafCount`. Without it Step I fails. +- `rollupManagerAddress` — **optional** address of the `PolygonRollupManager` (AgglayerManager) contract on L1. Used by Step WAIT to confirm the certificate's L1 settlement via the `VerifyBatchesTrustedAggregator` event. When unset it is resolved on-chain from `sovereignRollupAddr.rollupManager()` (PolygonConsensusBase). Step WAIT errors if neither `rollupManagerAddress` nor `sovereignRollupAddr` is set. +- `options.bridgeServiceURL` — base URL of the bridge service REST API. When set, Step E cross-checks unclaimed deposits against the bridge service and errors on discrepancies. +- `options.bridgeServiceType` — `"aggkit"` (default) or `"zkevm"`. Selects the API flavour used for the cross-check. +- `options.useAgglayerAdminToStepFCheck` — `true` (default). When `true`, Step F runs the agglayer admin balance check (`admin_getTokenBalance`, three-way comparison; requires `agglayerAdminURL`). When `false`, Step F skips the agglayer query and instead compares the LBT (Step 0) totals against the certificate bridge-exit sums offline (no `agglayerAdminURL` needed; skipped only if no LBT data exists). Set to `false` when no agglayer admin endpoint is available. +- `options.ignoreUnsupportedL2Events` — `false` (default). When `true`, the Step G lite syncer logs a warning and continues instead of aborting when it encounters an event that would invalidate a BridgeEvent-only reconstruction (`SetSovereignTokenAddress`, `MigrateLegacyToken`, `RemoveLegacySovereignTokenAddress`, `BackwardLET`, `ForwardLET`). The computed `NewLocalExitRoot` may then be incorrect — enable only to inspect such a chain knowingly. +- `options.verifyNewLocalExitRootUsingShadowFork` — `true` (default). When `true`, Step G2 spins up the Anvil shadow-fork, replays every exit against the real bridge contract, reorders the certificate to the on-chain deposit order with the on-chain metadata, and verifies the lite tree root against the contract's `getRoot()` (requires Anvil). When `false`, Step G2 computes the `NewLocalExitRoot` off-chain from the lite exit tree (G1's genesis→fork bridges + the certificate's exits) — fast, no Anvil, but it trusts the off-chain leaf encoding/metadata. + +Defaults applied by `LoadConfig`: + +- `l1BridgeAddress` defaults to `l2BridgeAddress` +- `l2NetworkId` defaults to `1` +- `options.blockRange` = 5000, `concurrencyLimit` = 20, `rpcBatchSize` = 200 +- `options.ignoreGenesisBalance` = `false` — when `false` (default), Step B aborts if any address has a non-zero ETH balance at block 0 (genesis preload guard). Set `true` to downgrade it to a warning, only for Kurtosis/test environments. +- `options.ignoreBalanceMismatch` = `false` — when `true`, Step F does not abort on token balance mismatches and instead produces a capped certificate. +- `options.useAgglayerAdminToStepFCheck` = `true` — when `false`, Step F skips the agglayer admin query and compares LBT (Step 0) vs certificate sums offline instead. +- Relative paths in `options.outputDir` and `signerConfig.Path` resolve from the directory containing the config file. + +`signerConfig` uses `signertypes.SignerConfig` (same type as aggsender's `AggsenderPrivateKey`). The JSON format is flat — `Method`, `Path`, `Password` are top-level keys (matching the TOML inline table style). Parsed by `parseSignerConfig` which splits `Method` out and puts the rest into `Config map[string]any`. + +## RPC layer (`rpc.go`, `worker.go`) + +- All RPC is plain JSON-RPC over HTTP — no go-ethereum client. +- `concurrentBatchRPC` sends calls in `rpcBatchSize`-sized batches, dispatches batches with a semaphore of size `concurrencyLimit`. +- `runWorkerPool` is a generic fan-out + fan-in over a slice of inputs with a configurable worker count. +- Retry logic uses `defaultRetries` (3) for `singleRPC`. +- `rpcDelayMs` inserts a sleep between batches for rate-limiting. + +## Invariants and gotchas + +- **Output dir:** All intermediate files land in `options.outputDir` (default `./output` relative to the config file). The dir is created automatically. +- **`parameters.json` and `output/` are git-ignored** — never commit them. +- **File chain:** Step D → `step-d-exit-certificate.json`; Step E → `step-e-exit-certificate.json` (adds unclaimed deposits); Step G2 → `step-g-reordered-certificate.json` (deposit-order exits); Step I reads `step-g-reordered-certificate.json` → `exit-certificate-final.json` (sets `NewLocalExitRoot` from G and `PrevLocalExitRoot` from H). Always submit `exit-certificate-final.json` (or the signed variant). +- **LBT resolution:** `resolveOrGenerateLBT` always runs Step 0 and saves `step-0-lbt.json`. +- **Step F reads from `step-d-exit-certificate.json`** for the balance check (not the final certificate), so the comparison reflects pure L2 exits before Step E additions. When capping is triggered, the caps are also applied to the final (Step E) certificate's `BridgeExits` in `runAll`, and saved as `step-f-capped-certificate.json`. +- **File chain with capping:** when `ignoreBalanceMismatch=true` produces a capped cert, the effective chain becomes: Step D → Step E → **Step F (capped)** → Step G → … Always check whether `step-f-capped-certificate.json` exists when investigating balance issues. +- **`--verbose` flag:** the logger defaults to `info` level; pass `--verbose` to enable `debug` output. +- **SC-locked value can be negative** when genesis state was pre-loaded or the LBT is stale — the genesis-balance guard (`ignoreGenesisBalance=false`, the default) catches this early. +- **`debug_traceTransaction` must be available** on the L2 RPC (Step A). Archive node required. +- **Step G2 requires Anvil only in shadow-fork mode** (`options.verifyNewLocalExitRootUsingShadowFork=true`; `anvil` binary in `$PATH`, from the Foundry toolchain). The default off-chain mode needs no Anvil. +- **FEP chains are not supported.** Only Pessimistic Proof certificates are generated. +- **`SetClaim` and `UpdatedUnsetGlobalIndexHashChain` events are not handled** — value from those flows may be missing. + +## Testing + +Run from the repo root: + +```bash +go test ./tools/exit_certificate/... +``` + +Or a single test: + +```bash +go test -v -run TestName ./tools/exit_certificate/ +``` + +Test files: `*_test.go` beside each step file. Use `require` (not `assert`). No mocks for the RPC layer — tests that hit network are integration tests in `integration_test.go` and require a live node. + +## Build + +From the repo root, using the top-level Makefile (binary is written to `target/exit_certificate`): + +```bash +make build-exit_certificate +``` + +Or directly with `go`: + +```bash +cd tools/exit_certificate +go build -o exit-certificate ./cmd +``` + +## Coding rules + +- **Contract binding**: Use the library "github.com/0xPolygon/cdk-contracts-tooling/contracts/". Here you can find all the contract, for instance, for bridge you can use: "github.com/0xPolygon/cdk-contracts-tooling/contracts/aggchain-multisig/agglayerbridgel2" diff --git a/tools/exit_certificate/README.md b/tools/exit_certificate/README.md new file mode 100644 index 000000000..27fcd788d --- /dev/null +++ b/tools/exit_certificate/README.md @@ -0,0 +1,527 @@ +# exit-certificate + +Generate exit certificates for a chain migration — scans L2 state, computes balances, and builds a certificate that bridges all value back to L1. + +## Overview + +**What it does:** The `exit-certificate` CLI scans an L2 chain from genesis to a target block, discovers all addresses with value, and produces an agglayer `Certificate` containing `BridgeExit` entries that transfer every balance (ETH + wrapped tokens) to the destination network. The certificate uses the native agglayer types directly — no conversion step is needed before submission. + +**When to use it:** Use when an aggchain needs to exit the Agglayer ecosystem. The tool ensures all value on the L2 is accounted for and packaged into a single certificate. + +## Requirements + +The chain being deprecated must meet **all** of the following conditions for the tool to produce a valid certificate. The first two are verified automatically by [Step CHECK](#step-check--verify-prerequisites); the last two are operational prerequisites you must ensure yourself. + +- **The network must be Pessimistic Proof (PP).** FEP (Finality by Execution Proof) chains are not supported. Step CHECK queries `AGGCHAINTYPE()` and aborts if the network is FEP. +- **The committee threshold must be 1.** Exactly one committee member must be required to approve certificates. Step CHECK queries the multisig threshold and aborts if it is greater than 1. +- **The network must have settled at least one certificate.** The tool needs a prior certificate to derive the `PreviousLocalExitRoot` (Step H); a chain that has never settled a certificate cannot be exited with this tool. +- **The network's sequencer must be stopped.** Halt the sequencer before running the tool so that no new bridges (or other state changes) are produced while the certificate is being built. New activity after the target block would not be reflected in the certificate. + +## Known limitations + +- **No unclaimed L1→L2 bridges are allowed.** Every bridge towards L2 must be claimed before starting the process. Outstanding (unclaimed) deposits must be claimed first; otherwise the generated certificate will not reflect them correctly. +- **`SetClaim` and `UpdatedUnsetGlobalIndexHashChain` events are not supported.** Transactions that emit these events on the bridge contract ([see contracts](https://github.com/agglayer/agglayer-contracts/tree/v12.2.3)) are not detected or accounted for. Value associated with these flows may be missing from the generated certificate. + +## Quick start + +```bash +# Build from the repo root — the binary is written to target/exit_certificate +make build-exit_certificate + +# Create your config from the example +cp tools/exit_certificate/parameters.json.example parameters.json + +# Edit parameters.json with your RPC URLs, bridge address, etc. +# Then run the tool +./target/exit_certificate --config parameters.json +``` + +There are also ready-to-use config files for the zkEVM networks in +[config-examples/](config-examples/) (`zkevm-cardona.toml`, `zkevm-mainnet.toml`). Copy the one that +matches your chain and fill in the fields documented in [config-examples/README.md](config-examples/README.md): + +```bash +# Use a prepared zkEVM config as a starting point +cp tools/exit_certificate/config-examples/zkevm-mainnet.toml parameters.toml + +# Edit parameters.toml (l1RpcUrl, exitAddress, signerConfig, etc.), then run +./target/exit_certificate --config parameters.toml +``` + +## Building + +From the repo root, using the top-level Makefile (binary is written to `target/exit_certificate`): + +```bash +make build-exit_certificate +``` + +Alternatively, build directly with `go` from `tools/exit_certificate/`: + +```bash +go build -o exit-certificate ./cmd +``` + +## Config file + +The tool uses a standalone config file in **JSON or TOML** format — the format is selected by the +file extension (`.toml` is parsed as TOML, anything else as JSON). Copy the example and fill in your +values: + +```bash +# JSON +cp parameters.json.example parameters.json + +# or TOML +cp parameters.toml.example parameters.toml +``` + +The field names are identical in both formats. Pass whichever you created with `--config`. + +> **Note:** `parameters.json`, `parameters.toml` and the `output/` directory are git-ignored — they are not committed to the repository. + +### Config fields + +| Field | Required | Description | +| :---: | :------: | :---------: | +| `l2RpcUrl` | Yes | L2 JSON-RPC endpoint. Must support `debug_traceTransaction` for Step A. | +| `l1RpcUrl` | Yes* | L1 JSON-RPC endpoint. Required by Step E (unclaimed deposit detection) and Step I (`L1InfoTreeLeafCount`). Without it Step E is silently skipped and Step I fails — the resulting certificate will be incomplete. | +| `l2BridgeAddress` | Yes | L2 bridge contract address. | +| `l1BridgeAddress` | No | L1 bridge contract address. Defaults to `l2BridgeAddress`. | +| `l2NetworkId` | No | L2 network ID. Defaults to `1`. | +| `targetBlock` | No | Target block for state capture. Accepts a decimal number (`"21000000"`), hex (`"0x1406f40"`), or a finality keyword: `"LatestBlock"`, `"FinalizedBlock"`, `"SafeBlock"`, `"PendingBlock"`. An optional negative offset can be appended (e.g. `"LatestBlock/-10"` = ten blocks before latest). Omitting the field or setting it to `""` defaults to `"LatestBlock"`. The keyword is resolved to a concrete block number at the start of Step 0 and saved to `step-0-l2_target_block.json`. All subsequent steps use that fixed number. | +| `exitAddress` | Yes | Address that receives SC-locked value exits on `destinationNetwork`. **Must be an address whose private key you control**, and **must not be the zero address** (`0x00…00`) — `LoadConfig` rejects both an empty value and the zero address, since these funds can only be recovered by signing from this address. **A multisig (e.g. a Gnosis Safe) is strongly recommended** over a single EOA, so that recovering these funds does not depend on a single private key. | +| `destinationNetwork` | No | Destination network for bridge exits. Defaults to `0` (L1). | +| `sovereignRollupAddr` | Yes* | Address of the `aggchainbase` contract on L1. Required by Step CHECK (network type and threshold verification). | +| `l1GlobalExitRootAddress` | Yes* | Address of `PolygonZkEVMGlobalExitRootV2` on L1. Required by Step I to fetch `L1InfoTreeLeafCount`. | +| `signerConfig` | No | Signer configuration object for Step SIGN. Same format as aggsender's `AggsenderPrivateKey`. Example: `{"Method": "local", "Path": "keystore.json", "Password": "pass"}`. | + +> **\*Required for specific steps:** `l1RpcUrl` is required by Steps E and I; `sovereignRollupAddr` is required by Step CHECK; `l1GlobalExitRootAddress` is required by Step I. Without them those steps fail. + +### Options + +| Field | Default | Description | +| :---: | :-----: | :---------: | +| `blockRange` | `5000` | Block range per `eth_getLogs` query (Steps 0, B, E). | +| `stepAWindowSize` | `5000` | Number of blocks loaded into memory per iteration in Step A (address collection via `debug_traceTransaction`). Set independently when trace calls need a different chunk size than log queries. | +| `concurrencyLimit` | `20` | Max concurrent RPC requests. | +| `rpcBatchSize` | `200` | Max calls per JSON-RPC batch request. | +| `rpcDelayMs` | `0` | Delay between RPC batches (rate limiting). | +| `outputDir` | `./output` | Directory for intermediate and final output files. Relative paths resolve from the config file directory. | +| `l1StartBlock` | `0` | L1 block to start scanning from (Step E). | +| `l2StartBlock` | `0` | L2 block to start scanning from (Step A). Useful when genesis activity can be skipped. | +| `agglayerAdminURL` | `""` | Agglayer admin RPC endpoint. Required for Step F in agglayer mode (Step F errors if it runs without this set). Not needed when `useAgglayerAdminToStepFCheck: false` (offline LBT mode). | +| `agglayerAdminToken` | `""` | Bearer token for authenticating requests to `agglayerAdminURL`. Required when the admin endpoint is protected by Google Cloud IAP. See [Authenticating with IAP](#authenticating-with-iap) for how to obtain it. | +| `agglayerClient` | `{}` | Agglayer gRPC client config (same as aggsender's `agglayer.ClientConfig`). Set at least `agglayerClient.GRPC.URL`. Required for Steps H, SUBMIT, and WAIT. | +| `useAgglayerAdminToStepFCheck` | `true` | Selects the Step F comparison source. When `true` (default), Step F queries the agglayer admin API (`admin_getTokenBalance`) and does a three-way check (LBT == agglayer == certificate; requires `agglayerAdminURL`). When `false`, it skips the agglayer query and instead compares the LBT (Step 0) totals against the certificate bridge-exit sums offline (no `agglayerAdminURL` needed; skipped only if no LBT data exists). | +| `ignoreGenesisBalance` | `false` | When `false` (default), Step B aborts if any address has a non-zero ETH balance at block 0 (genesis preload guard). Set `true` to downgrade it to a warning, only for Kurtosis or test environments. | +| `ignoreOnTraceError` | `false` | When `true`, Step A skips transactions whose `debug_traceTransaction` call fails instead of aborting. Failed tx hashes are saved to `step-a-failed-traces.json`. | +| `ignoreBalanceMismatch` | `false` | When `true`, Step F does not abort the pipeline on token balance mismatches. Instead it produces a capped certificate (`step-f-capped-certificate.json`) where each token's bridge exits are proportionally scaled down to `min(agglayer, lbt)`. See [Step F](#step-f--agglayer-token-balance-verification) for details. | +| `ignoreUnclaimed` | `false` | When `true`, Step E detects and logs unclaimed deposits but leaves the certificate unchanged. When `false` (default), any unclaimed asset deposit causes the pipeline to error. | +| `bridgeServiceURL` | `""` | Base URL of the bridge service REST API. When set, Step E cross-checks its unclaimed deposit set against the bridge service and returns an error on any discrepancy. | +| `bridgeServiceType` | `"aggkit"` | Bridge service API flavour. `"aggkit"` uses `GET /bridge/v1/bridges` (aggkit bridge service); `"zkevm"` uses `GET /pending-bridges` (zkevm-bridge-service). | +| `extraErc20Contracts` | `[]` | Optional list of ERC-20 contract addresses to decompose into individual holder balances in Step B3. For each address the tool calls `balanceOf` for every EOA collected in Step A. Example: `["0xAbc...123", "0xDef...456"]`. | +| `ignoreUnsupportedL2Events` | `false` | When `true`, the Step G lite syncer logs a warning and continues instead of aborting when it sees an L2 event that would invalidate a BridgeEvent-only reconstruction (`SetSovereignTokenAddress`, `MigrateLegacyToken`, `RemoveLegacySovereignTokenAddress`, `BackwardLET`, `ForwardLET`). The computed `NewLocalExitRoot` may then be incorrect — enable only to knowingly inspect such a chain. | +| `verifyNewLocalExitRootUsingShadowFork` | `true` | Selects the Step G2 mode. When `true` (default), Step G2 spins up an Anvil shadow-fork, replays every bridge exit against the real bridge contract, reorders the certificate to the on-chain deposit order, and verifies the computed `NewLocalExitRoot` against the contract's `getRoot()` (requires `anvil` in `$PATH`). When `false`, Step G2 computes the `NewLocalExitRoot` off-chain from the lite exit tree (no Anvil) — much faster, but it trusts the off-chain leaf encoding/metadata. See [Step G](#step-g--compute-newlocalexitroot) for details. | + +### Important configuration notes + +**`l1RpcUrl` — required in practice** + +Although marked optional, `l1RpcUrl` is needed for Step E (unclaimed deposit detection) and Step I (`L1InfoTreeLeafCount`). In a real exit scenario you should always set it. Without it, Step E is silently skipped and the certificate may be missing unclaimed L1→L2 deposits. + +**`exitAddress` — required, keep the private key** + +SC-locked value (tokens held in smart contracts) is bridged to `exitAddress` on the destination network. The field is **mandatory**: `LoadConfig` errors if it is missing or set to the zero address (`0x00…00`). Use an address **whose private key you control** — once the certificate is settled, those funds can only be recovered by signing transactions from that address. If the key is lost, the value is permanently inaccessible. + +For this reason, **a multisig wallet (e.g. a [Gnosis Safe](https://safe.global/)) is strongly recommended** over a single EOA. Because these funds can only ever be recovered by signing from `exitAddress`, spreading control across several signers removes the single point of failure: no single lost or compromised key can lock up or steal the exited value. + +**`agglayerClient` — required for Steps H, SUBMIT, and WAIT** + +Uses the same `agglayer.ClientConfig` struct as aggsender. At minimum provide the gRPC URL; unset fields default to the same values used by aggsender: + +```json +"agglayerClient": { + "GRPC": { + "URL": "localhost:50051" + } +} +``` + +Full example with all fields (timeouts accept Go duration strings: `"5s"`, `"1m"`, etc.): + +```json +"agglayerClient": { + "GRPC": { + "URL": "localhost:50051", + "RequestTimeout": "30s", + "MinConnectTimeout": "5s", + "UseTLS": false, + "Retry": { + "MaxAttempts": 3, + "InitialBackoff": "1s", + "MaxBackoff": "10s", + "BackoffMultiplier": 2.0 + } + } +} +``` + +**`signerConfig` — required to sign and submit** + +Step SIGN requires a signer configuration. Use the same JSON format as aggsender's `AggsenderPrivateKey`: + +```json +"signerConfig": { + "Method": "local", + "Path": "/path/to/keystore.json", + "Password": "your-password" +} +``` + +Without this field, Step SIGN is skipped when running the full pipeline and you will need to sign manually. + +The example above uses a local keystore file. Other backends (GCP KMS, AWS KMS, etc.) are also supported. For the full list of signer methods and their configuration options see the [go_signer](https://github.com/agglayer/go_signer) repository. + + +#### Options to skip failing checks + +Some options let you continue past conditions that would otherwise abort the pipeline. Use them with care: + +| Option | Default | When to change | +| ------ | ------- | -------------- | +| `ignoreOnTraceError` | `false` | Set to `true` if some transactions fail `debug_traceTransaction` (e.g. the node does not have full archive traces for old blocks). Failed hashes are saved to `step-a-failed-traces.json` — review them to confirm the missing value is acceptable. | +| `ignoreGenesisBalance` | `false` | Set to `true` only for Kurtosis or test environments where addresses are pre-funded at genesis. In production, a non-zero genesis balance indicates a misconfiguration, so leave it `false` to abort. | +| `ignoreUnclaimed` | `false` | Set to `true` to proceed even when unclaimed L1→L2 asset deposits are detected. The deposits are logged with a warning but the certificate is left unchanged. Only safe if you have independently verified the unclaimed deposits are negligible or already handled. | + +## Commands + +### Run full pipeline + +```bash +./target/exit_certificate --config parameters.json +``` + +Runs all steps sequentially: CHECK → 0 → A → B → C → D → E → F → G → H → I → SIGN (if `signerConfig` is set). + +This produces and signs the certificate but **does not submit it**. SUBMIT and WAIT are intentionally left out of the default pipeline — once you have reviewed the signed certificate, run them explicitly: + +```bash +# Send the signed certificate to the agglayer +./target/exit_certificate --config parameters.json --step submit + +# Wait for it to settle (on the agglayer and on L1) +./target/exit_certificate --config parameters.json --step wait +``` + +| Step | Name | What it does | +| :--: | ---- | ------------ | +| CHECK | Verify prerequisites | Checks Anvil, L1 RPC, network type (PP only), threshold = 1, no custom gas token. | +| 0 | Generate LBT | Resolves `targetBlock` to a concrete block number, then scans `NewWrappedToken` events and fetches `totalSupply` per wrapped token at that block. | +| A | Collect addresses | A1: traces every L2 transaction via `debug_traceTransaction` and collects all addresses that touched state. A2: for any transaction whose trace failed in A1, recovers its addresses from the tx receipt (`eth_getTransactionReceipt`). | +| B | EOA balances + ERC-20 detection | B1: classifies addresses and fetches ETH/token balances for EOAs. B2: probes contracts for the ERC-20 interface and checks if they hold tracked wrapped tokens. B3: fetches holder breakdowns for `extraErc20Contracts` (skips any already processed by B2). | +| C | SC-locked value | Computes value locked in contracts: `SC_locked = LBT_totalSupply − EOA_accumulated` per token. | +| D | Build certificate | Creates the `Certificate` with `BridgeExit` entries for every (EOA, token) pair and every token with SC-locked value. | +| E | Unclaimed deposits | Scans L1 for unclaimed `BridgeEvent` deposits targeting L2. Message deposits (`leaf_type=1`) are saved to `step-e-unclaimed-messages.json` and never added to the certificate. Asset deposits (`leaf_type=0`): if none are found the certificate is passed through unchanged; if any are found and `ignoreUnclaimed=true` they are logged but the certificate remains unchanged; if found and `ignoreUnclaimed=false` the pipeline errors (Merkle proof support not yet implemented). Optionally cross-checks against a bridge service. | +| F | Balance verification | Three-way comparison (LBT, agglayer, certificate) per token. Aborts on mismatch by default; with `ignoreBalanceMismatch=true` produces a proportionally capped certificate. With `useAgglayerAdminToStepFCheck=false` it skips the agglayer query and does an offline LBT-vs-certificate comparison instead. | +| G | NewLocalExitRoot | G1: syncs the L2 bridge history from genesis up to `targetBlock` into a lite DB and resolves the shadow-fork block. G2: computes the `NewLocalExitRoot` — by default shadow-forks L2 via Anvil, replays all bridge exits, and reads the resulting root from the forked bridge contract (or computes it off-chain when `verifyNewLocalExitRootUsingShadowFork=false`). | +| H | PreviousLocalExitRoot | Fetches `settled_ler` from the agglayer gRPC to obtain the previous LER and the next certificate height. | +| I | Assemble final cert | Applies `NewLocalExitRoot` (G), `PreviousLocalExitRoot` + height (H), bridge exit metadata, and `L1InfoTreeLeafCount` (from the latest `UpdateL1InfoTreeV2` event on L1). | +| SIGN | Sign certificate | Hashes the certificate and signs it with the configured keystore; wraps the signature in `AggchainDataMultisig`. | +| SUBMIT | Send to agglayer | Sends the signed certificate to the agglayer via gRPC. **Not part of the default pipeline.** | +| WAIT | Wait for settlement | Polls `GetCertificateHeader` every 5 s until the certificate is `Settled` or `InError`, then confirms the settlement on L1 (`VerifyBatchesTrustedAggregator` on the RollupManager + the accompanying `UpdateL1InfoTree`/`UpdateL1InfoTreeV2` events). **Not part of the default pipeline.** | + +Steps SUBMIT and WAIT are **not** part of the default pipeline — they must be triggered explicitly. + +### Run one or more steps + +```bash +# Single step +./target/exit_certificate --config parameters.json --step h + +# Multiple steps (comma-separated, run in the given order) +./target/exit_certificate --config parameters.json --step h,i,sign +./target/exit_certificate --config parameters.json --step "sign, submit" + +# Ranges (inclusive) +./target/exit_certificate --config parameters.json --step a-c # a, b, c +./target/exit_certificate --config parameters.json --step g- # g, h, i, sign (open range stops at sign) +./target/exit_certificate --config parameters.json --step 0-wait # every step, including submit and wait +``` + +Each step reads its dependencies from the output directory (files written by prior steps). +Spaces around commas are ignored. Execution stops at the first step that fails. + +Ranges use `from-to` (inclusive). An open-ended `from-` runs through `sign`; `submit` and `wait` are left out of open ranges and must be named explicitly (e.g. `0-wait` to run the entire flow end to end). + +### CLI flags + +| Flag | Short | Default | Description | +| :--: | :---: | :-----: | :---------: | +| `--config` | `-c` | `parameters.json` | Path to the config file. | +| `--step` | — | `all` | Step(s) to run. Accepts `all`; a single step name; a comma-separated list (e.g. `h,i,sign`); or a range `from-to` (inclusive, e.g. `a-c` → `a,b,c`). An **open-ended** range `from-` runs through `sign` (e.g. `g-` → `g,h,i,sign`); `submit`/`wait` are excluded from open ranges and must be named explicitly — use `0-wait` to run every step. Valid names: `check`, `0`, `a`/`a1`/`a2`, `b`/`b1`/`b2`/`b3`, `c`–`f`, `g`/`g1`/`g2`, `h`, `i`, `sign`, `submit`, `wait`. The aliases `a`, `b`, `g` expand to their sub-steps and also work as range bounds. | +| `--verbose` | — | `false` | Enable debug logging. Without this flag only `info`, `warn` and `error` messages are shown. | + +## Pipeline steps + +### Step CHECK — Verify prerequisites + +Runs automatically as the first step of the full pipeline. Can also be run individually: + +```bash +./target/exit_certificate --config parameters.json --step check +``` + +All checks run regardless of individual failures; a combined error lists every failed check. + +1. **Anvil installed** — `anvil` must be in `$PATH` (required by Step G2 only when `options.verifyNewLocalExitRootUsingShadowFork=true`). Fails with a clear error pointing to [getfoundry.sh](https://getfoundry.sh) if missing. +2. **L1 RPC reachable** — dials `l1RpcUrl` and calls `eth_blockNumber`. Fails if not set or unreachable. +3. **L2 network ID matches bridge** — calls `NetworkID()` on the L2 bridge contract and verifies it matches `l2NetworkId` in config. +4. **`sovereignRollupAddr` is set** — required; fails if zero address. +5. **Network type is PP** — queries `AGGCHAINTYPE()` on the `aggchainbase` contract at `sovereignRollupAddr` on L1. FEP is not supported. Only runs if checks 2 and 4 passed. +6. **Threshold is 1** — queries the multisig threshold. Fails if > 1. Also verifies the bridge address on the contract matches config. Logs all committee signers and their URLs. Only runs if checks 2 and 4 passed. +7. **No custom gas token** — calls `gasTokenAddress()`/`gasTokenNetwork()` on the L2 bridge. Fails if a non-zero gas token is configured (not supported). + +**Output:** `step-check-result.json` + +### Step 0 — Generate LBT (Local Balance Tree) + +#### Target block resolution + +The `targetBlock` config field accepts a finality keyword, an optional offset, or a concrete block number. Step 0 resolves it to a `uint64` before doing any work: + +| `targetBlock` value | How it is resolved | +| ------------------- | ------------------ | +| `""` or omitted | Equivalent to `"LatestBlock"` | +| `"LatestBlock"` | `eth_getBlockByNumber("latest")` on the L2 RPC | +| `"FinalizedBlock"` | `eth_getBlockByNumber("finalized")` on the L2 RPC | +| `"SafeBlock"` | `eth_getBlockByNumber("safe")` on the L2 RPC | +| `"PendingBlock"` | `eth_getBlockByNumber("pending")` on the L2 RPC | +| `"LatestBlock/-10"` | Latest block number minus 10 | +| `"21000000"` / `"0x1406f40"` | Used directly, no RPC call needed | + +The resolved number is written to `step-0-l2_target_block.json` and used as a fixed reference by all subsequent steps (A, B, G). When running individual steps the file must exist (produced by a prior Step 0 run). + +#### Step 0 — LBT generation + +After resolution, Step 0 scans the L2 bridge contract for `NewWrappedToken` events and fetches the `totalSupply` of each wrapped token at the resolved block. It also applies any `SetSovereignTokenAddress` overrides (remapped wrapped addresses), computes the unlocked native token balance, and checks for a WETH entry if the chain has a custom gas token. + +**Output:** `step-0-l2_target_block.json` (resolved block number), `step-0-lbt.json` (LBT entries) + +### Step A — Collect addresses + +Scans all blocks from `l2StartBlock` to `targetBlock` and collects every address that participated in any transaction. Step A runs two sub-steps in sequence: A1 and A2. Running `--step a` executes both. + +#### Step A1 — Collect addresses via tracing + +Collects touched addresses using `debug_traceTransaction` (prestateTracer, diffMode). Blocks are scanned in windows of `options.stepAWindowSize` to bound peak memory usage. + +1. Scan — `eth_getBlockByNumber` (headers only, `false`) across all blocks → tx hashes are included directly in the response +2. Trace — `debug_traceTransaction` (prestateTracer, diffMode) per hash to extract pre/post state addresses + +Transactions whose trace fails are recorded as failed traces (unless `ignoreOnTraceError` aborts the run; see the options table). + +**Output:** `step-a1-addresses.json`, `step-a1-failed-traces.json` + +#### Step A2 — Recover addresses from receipts + +For each trace that failed in A1, calls `eth_getTransactionReceipt` and extracts every address found in the receipt (sender, recipient, created contract, and log emitters). Failed receipt fetches are logged as warnings and skipped rather than aborting. The recovered addresses are merged with the A1 set to produce the combined address list. + +**Output:** `step-a2-addresses.json`, `step-a-addresses.json` (combined A1 + A2 addresses — the file consumed by later steps) + +### Step B — EOA balance checking + ERC-20 detection + +Step B runs three sub-steps in sequence: B1, B2, and B3. Running `--step b` executes all three. + +#### Step B1 — EOA classification and balance fetching + +Classifies addresses as EOA vs contract, then queries ETH balance and every wrapped-token balance at `targetBlock` for all EOAs. The wrapped token list comes from the LBT data (Step 0). + +**Phases:** + +1. `eth_getCode` to classify EOA vs contract +2. `eth_getBalance` for all EOAs +3. `balanceOf` calls per token × per EOA (token list from LBT) + +**Output:** `step-b-eoa-balances.json`, `step-b-accumulated.json`, `step-b-contract-addresses.json` + +#### Step B2 — ERC-20 detection in contracts + +Probes every contract address for the ERC-20 interface by calling `totalSupply()`. For each ERC-20 found, checks whether it holds any of the tracked wrapped tokens: + +- Holds at least one tracked token → **DetectedERC20** (relevant to the certificate) +- Holds none → **DiscardedERC20** (no tracked value locked inside) + +**Output:** `step-b2-detected-erc20s.json`, `step-b2-discarded-erc20s.json` + +#### Step B3 — Extra ERC-20 holder decomposition + +Fetches the per-EOA token balance for each contract listed in `options.extraErc20Contracts`. These are ERC-20 contracts that should be decomposed into individual holder balances regardless of whether they were discovered by Step B2. + +Skipped automatically when `options.extraErc20Contracts` is empty. + +**Output:** `step-b3-erc20-holders.json` + +### Step C — SC-locked value extraction + +Computes value locked in smart contracts using: `SC_locked = LBT_totalSupply - accumulated_EOA_balances`. Uses the LBT data (Step 0) for total supply per token. + +**Output:** `step-c-sc-locked-values.json` + +### Step D — Build exit certificate + +Creates the agglayer `Certificate` with `BridgeExit` entries for: + +1. Every (EOA, token) pair with a non-zero balance → exits to the same address on the destination network +2. Every token with SC-locked value → exits to `exitAddress` on the destination network + +**Output:** `step-d-exit-certificate.json` + +### Step E — Unclaimed L1→L2 bridge deposits + +Scans L1 for `BridgeEvent` events targeting the L2 and checks each deposit against `isClaimed` on the L2 bridge. Deposits are split by leaf type: + +- **Message deposits (`leaf_type=1`)** — never added to the certificate. Saved to `step-e-unclaimed-messages.json` for review. +- **Asset deposits (`leaf_type=0`)** — three outcomes depending on what is found: + - **No unclaimed asset deposits** → step completes, certificate passed through unchanged. + - **Unclaimed asset deposits found + `ignoreUnclaimed=true`** → deposits are detected, amounts logged with a warning, certificate left unchanged. + - **Unclaimed asset deposits found + `ignoreUnclaimed=false`** → pipeline **errors**. Adding unclaimed deposits to the certificate requires Merkle proofs which are not yet implemented. + +When `bridgeServiceURL` is set, Step E compares its detected unclaimed set against the bridge service's pending-bridges and errors if the sets differ. Supports both aggkit (`/bridge/v1/bridges`) and zkevm-bridge-service (`/pending-bridges`) via `bridgeServiceType`. + +Requires `l1RpcUrl`. + +**Output:** `step-e-unclaimed-bridges.json`, `step-e-unclaimed-messages.json`, `step-e-exit-certificate.json` + +### Step F — Agglayer token balance verification + +Step F has two modes selected by `options.useAgglayerAdminToStepFCheck` (default `true`): + +- **Agglayer mode (`true`):** queries the agglayer admin API (`admin_getTokenBalance`) and performs a **three-way comparison** per token (requires `agglayerAdminURL`). +- **Offline mode (`false`):** **no agglayer query** — performs a **two-way LBT (Step 0) vs certificate** comparison per token. No `agglayerAdminURL` needed; when no LBT data is available there is nothing to compare and the step is skipped. `step-f-token-balances.json` is not written in this mode. + +The three-way comparison (agglayer mode): + +| Source | What it represents | +| ------ | ------------------ | +| **LBT** (Step 0) | `totalSupply` of the wrapped token at `targetBlock` — what the L2 contract holds | +| **Agglayer** | What the agglayer believes is locked for this L2 network | +| **Certificate** | Sum of all `BridgeExit` amounts for that token | + +All compared values must be equal. Each token is logged with ✅ or ❌: + +```text +✅ (network=1 addr=0xabc...): lbt=1000 certificate=1000 agglayer=1000 +❌ MISMATCH (network=1 addr=0xdef...): lbt=800 certificate=1000 agglayer=900 +``` + +**If mismatches are found:** + +- By default Step F **aborts the pipeline** with an error. +- Set `options.ignoreBalanceMismatch: true` to continue instead. In that case the step produces `step-f-capped-certificate.json`, where each mismatched token's bridge exits are proportionally scaled down to `min(agglayer, lbt)`. Subsequent steps in the pipeline (G, H, I) automatically use this capped certificate. + +When running Step G individually it also prefers `step-f-capped-certificate.json` over `step-e-exit-certificate.json` if the capped file exists (logged with ⚠️). + +LBT data comes from `step-0-lbt.json`. In agglayer mode, if it is not available the comparison falls back to two-way (certificate vs agglayer only); in offline mode, missing LBT means there is nothing to compare and the step is skipped. + +In agglayer mode `agglayerAdminURL` must be set (errors otherwise); offline mode needs no admin endpoint. + +**Reads:** `step-d-exit-certificate.json`, `step-0-lbt.json` + +**Output:** `step-f-token-balances.json`, `step-f-checks.json`, `step-f-capped-certificate.json` *(only when mismatches exist and `ignoreBalanceMismatch=true`)* + +### Step G — Compute NewLocalExitRoot + +Split into **G1** (sync the L2 bridge history from genesis up to the target block into a lite DB, resolving the shadow-fork block) and **G2** (compute the `new_local_exit_root`). By default (`options.verifyNewLocalExitRootUsingShadowFork=true`) G2 replays every `bridge_exit` against a shadow-fork of the L2 chain via [Anvil](https://getfoundry.sh), reorders the certificate to the on-chain deposit order, and verifies the lite exit tree root against the forked contract's `getRoot()`. Set the option to `false` to instead compute the root **off-chain** from the lite exit tree (G1's bridges + the certificate's exits, in order) without Anvil — faster, but it trusts the off-chain leaf encoding/metadata. + +**Anvil is required in the default shadow-fork mode** (`anvil` binary in `$PATH`); the off-chain mode (`verifyNewLocalExitRootUsingShadowFork=false`) needs no Anvil. When the certificate has no bridge exits, the canonical empty LER is used. + +**Reads:** `step-f-capped-certificate.json` if it exists (produced by Step F when `ignoreBalanceMismatch=true`), otherwise `step-e-exit-certificate.json`. + +**Output:** + +- **G1:** `step-g1-shadow-fork-block.json` (resolved shadow-fork block) and the lite syncer DB `output/step-g1-l2bridgesyncerlite.sqlite`. +- **G2:** `step-g-new-local-exit-root.json`, `step-g-reordered-certificate.json` (the deposit-order certificate Step I consumes) and `step-g-l2bridgesyncerlite.sqlite` (working copy of the G1 DB with the tree built); in shadow-fork mode also `step-g-failed-exit.json` *(only on replay failure)*. + +### Step H — Fetch PreviousLocalExitRoot + +Calls `interop_getNetworkInfo` on the agglayer JSON-RPC and reads the `settled_ler` for the L2 network. If no certificate has been settled yet, `PreviousLocalExitRoot` is zero. + +Requires `agglayerClient.GRPC.URL` in options. + +**Output:** `step-h-previous-local-exit-root.json` + +### Step I — Assemble final certificate + +Takes the deposit-order certificate produced by Step G and applies: + +- `NewLocalExitRoot` from Step G +- `PreviousLocalExitRoot` and certificate height from Step H +- `L1InfoTreeLeafCount` — scans L1 backwards from the latest L1 block for the most recent `UpdateL1InfoTreeV2` event on the `l1GlobalExitRootAddress` contract. Requires `l1RpcUrl` and `l1GlobalExitRootAddress` in config. + +**Reads:** `step-g-reordered-certificate.json` (run Step G first — there is no fallback to the Step E / Step F certificates, so the final certificate always matches the computed `NewLocalExitRoot`); plus `step-g-new-local-exit-root.json` and `step-h-previous-local-exit-root.json`. + +**Output:** `exit-certificate-final.json` + +### Step SIGN — Sign the certificate + +Signs `exit-certificate-final.json` with the configured keystore and writes `exit-certificate-signed.json`. The signature is embedded in `AggchainData` as an `AggchainDataMultisig` ECDSA entry. + +Requires `signerConfig` in config (same format as aggsender's `AggsenderPrivateKey`). Skipped automatically in `all` mode when `signerConfig` is not set. + +**Reads:** `exit-certificate-final.json` + +**Output:** `exit-certificate-signed.json` + +### Step SUBMIT — Send certificate to agglayer + +Sends `exit-certificate-signed.json` to the agglayer via gRPC and returns the certificate hash. **Not part of the default pipeline** — must be triggered with `--step submit`. + +Before submitting, it: + +1. Checks for a pending certificate on the network (`GetLatestPendingCertificateHeader`). If one exists and is **not closed**, the step **errors** — you must wait for it to settle before submitting a new one. +2. Captures the **latest L1 block right before submission** (`eth_blockNumber` on `l1RpcUrl`). This is recorded in the result and marks the L1 starting point from which Step WAIT looks for the certificate's L1 settlement. + +Requires `agglayerClient.GRPC.URL` and `l1RpcUrl` in config. + +**Reads:** `exit-certificate-signed.json` + +**Output:** `step-submit-result.json` (`certificateHash` + `l1LatestBlockBeforeSubmittingCertificate`) + +### Step WAIT — Wait for certificate settlement + +Polls the agglayer until the submitted certificate reaches a final state, then confirms the settlement on L1. **Not part of the default pipeline** — must be triggered with `--step wait`. + +Two phases: + +1. **Agglayer settlement** — polls `GetCertificateHeader` by hash every 5 seconds until the submitted certificate is `Settled` (success) or `InError` (returns an error). Logs the settlement tx hash on success. +2. **L1 settlement confirmation** — scans the RollupManager contract on L1 from `l1LatestBlockBeforeSubmittingCertificate` (from the submit result) to the **finalized** block for the `VerifyBatchesTrustedAggregator` event matching the rollupID (`l2NetworkId`) and the certificate's `NewLocalExitRoot`. The RollupManager address is `rollupManagerAddress` if set, otherwise resolved on-chain from `sovereignRollupAddr.rollupManager()`. It re-resolves the finalized block and re-scans every 5 seconds until found. In that same L1 block it then reads the last `UpdateL1InfoTree` and `UpdateL1InfoTreeV2` events emitted by `l1GlobalExitRootAddress` (the global-exit-root update accompanying the settlement). + +Requires `agglayerClient.GRPC.URL`, `l1RpcUrl`, and `l1GlobalExitRootAddress` in config, plus either `rollupManagerAddress` or `sovereignRollupAddr` to resolve the RollupManager. + +**Reads:** `step-submit-result.json` (certificate hash + the captured pre-submission L1 block) + +**Output:** `step-wait-result.json` (final status, settlement tx hash, the L1 `VerifyBatchesTrustedAggregator` block/tx, and the `UpdateL1InfoTree` / `UpdateL1InfoTreeV2` events in that block) + +## Result + +After the full flow completes (the certificate is built and signed, then SUBMIT and WAIT succeed): + +- **The agglayer holds every bridge exit in the certificate.** Once the certificate settles, the agglayer accounts for all of the certificate's `bridge_exits` — the value has been bridged out of the L2 and is ready to be claimed on the destination network. +- **The files needed to claim those bridges have been generated.** Claiming each exit requires calling `claimAsset` on the bridge contract with Merkle proofs and the exit roots. The companion [`exit_certificate_claimer`](../exit_certificate_claimer/README.md) tool consumes the exit_certificate output and produces the parameters for each `claimAsset` call. + +The output files the claimer needs are: + +| File | Used for | +| ---- | -------- | +| `exit-certificate-signed.json` | The signed certificate — source of each exit's `originNetwork`, `originTokenAddress`, `destinationNetwork`, `destinationAddress`, `amount`, `metadata`. | +| `step-g-l2bridgesyncerlite.sqlite` | The L2 local exit tree — used to build the `smtProofLocalExitRoot` proof of each leaf against `new_local_exit_root`. | +| `step-wait-result.json` | The WAIT step's L1 settlement record (`VerifyBatchesTrustedAggregator` + the `UpdateL1InfoTree`/`UpdateL1InfoTreeV2` events) used to anchor the claim to the settled global exit root. | + +## Testing + +From the repository root: + +```bash +go test ./tools/exit_certificate/... +``` diff --git a/tools/exit_certificate/bridgesyncerlite/downloader.go b/tools/exit_certificate/bridgesyncerlite/downloader.go new file mode 100644 index 000000000..2edf506d4 --- /dev/null +++ b/tools/exit_certificate/bridgesyncerlite/downloader.go @@ -0,0 +1,263 @@ +package bridgesyncerlite + +import ( + "context" + "fmt" + "math/big" + "sync/atomic" + "time" + + "github.com/0xPolygon/cdk-contracts-tooling/contracts/aggchain-multisig/agglayerbridge" + "github.com/agglayer/aggkit/log" + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "golang.org/x/sync/errgroup" +) + +// progressLogInterval is how often fetchBridges reports parallel-fetch progress with an ETA. +const progressLogInterval = 5 * time.Second + +// percentMultiplier converts a [0,1] fraction to a percentage for progress logging. +const percentMultiplier = 100 + +var ( + // bridgeEventSignature is the only event this syncer ingests. + bridgeEventSignature = crypto.Keccak256Hash([]byte( + "BridgeEvent(uint8,uint32,address,uint32,address,uint256,bytes,uint32)", + )) + + // forbiddenEventSignatures are events whose presence means the bridge state cannot be + // reconstructed from BridgeEvent logs alone (token remappings, legacy migrations, LET + // rollbacks/advances). Detecting any of them aborts the sync. + forbiddenEventSignatures = buildForbiddenEventSignatures() +) + +func buildForbiddenEventSignatures() map[common.Hash]string { + sigs := map[string]string{ + "SetSovereignTokenAddress(uint32,address,address,bool)": "SetSovereignTokenAddress", + "MigrateLegacyToken(address,address,address,uint256)": "MigrateLegacyToken", + "RemoveLegacySovereignTokenAddress(address)": "RemoveLegacySovereignTokenAddress", + "BackwardLET(uint256,bytes32,uint256,bytes32)": "BackwardLET", + "ForwardLET(uint256,bytes32,uint256,bytes32,bytes)": "ForwardLET", + } + out := make(map[common.Hash]string, len(sigs)) + for sig, name := range sigs { + out[crypto.Keccak256Hash([]byte(sig))] = name + } + return out +} + +// fetchBridges reads every BridgeEvent emitted by the bridge contract in [fromBlock, toBlock], +// splitting the range into BlockChunkSize-sized windows and querying them in parallel (bounded by +// Concurrency). It returns the parsed leaves unsorted; the caller orders them by deposit count. +// If any forbidden event is seen in any window, the whole fetch is aborted with an error. +func (s *BridgeSyncerLite) fetchBridges(ctx context.Context, fromBlock, toBlock uint64) ([]BridgeLeaf, error) { + if s.client == nil { + return nil, fmt.Errorf("fetching bridges requires an RPC-backed syncer (set Config.RPCURL)") + } + if fromBlock > toBlock { + return nil, fmt.Errorf("invalid block range: fromBlock %d > toBlock %d", fromBlock, toBlock) + } + + type window struct{ from, to uint64 } + var windows []window + for from := fromBlock; from <= toBlock; from += s.cfg.BlockChunkSize { + to := min(from+s.cfg.BlockChunkSize-1, toBlock) + windows = append(windows, window{from, to}) + } + + // Report progress with an ETA while the windows are fetched in parallel. Log an initial line up + // front (so there's always feedback that the fetch started, even if it finishes before the first + // tick) and then periodic progress; the summary line is logged once everything is done. + s.log.Infof("fetching BridgeEvent logs [%d..%d] in %d windows of %d blocks (concurrency %d)...", + fromBlock, toBlock, len(windows), s.cfg.BlockChunkSize, s.cfg.Concurrency) + var completed atomic.Int64 + start := time.Now() + progressCtx, stopProgress := context.WithCancel(ctx) + defer stopProgress() + go s.reportFetchProgress(progressCtx, start, &completed, int64(len(windows)), fromBlock, toBlock) + + results := make([][]BridgeLeaf, len(windows)) + g, gctx := errgroup.WithContext(ctx) + g.SetLimit(s.cfg.Concurrency) + for i, w := range windows { + g.Go(func() error { + leaves, err := s.fetchWindow(gctx, w.from, w.to) + if err != nil { + return fmt.Errorf("fetch logs for blocks [%d..%d]: %w", w.from, w.to, err) + } + results[i] = leaves + completed.Add(1) + return nil + }) + } + if err := g.Wait(); err != nil { + return nil, err + } + stopProgress() + + total := 0 + for _, r := range results { + total += len(r) + } + all := make([]BridgeLeaf, 0, total) + for _, r := range results { + all = append(all, r...) + } + s.log.Infof("fetched %d BridgeEvent logs from blocks [%d..%d] in %s", + len(all), fromBlock, toBlock, time.Since(start).Truncate(time.Second)) + return all, nil +} + +// etaRateWindow is the trailing time horizon over which reportFetchProgress measures throughput to +// estimate the ETA. The rate is computed purely from samples within this window, so it always +// reflects the recent completion rate of the dense high-block tail rather than the much faster start. +const etaRateWindow = 30 * time.Second + +// progressSample is a point-in-time observation of how many windows had completed. +type progressSample struct { + t time.Time + done int64 +} + +// reportFetchProgress periodically logs how many block windows have been fetched and an ETA for the +// rest. The ETA extrapolates from throughput measured over a trailing window (etaRateWindow) rather +// than the lifetime average. Low-block windows are typically empty and complete far faster than the +// dense high-block tail — often the bulk finishes within the first interval — so any estimate +// anchored to the start (a lifetime average, an EWMA seeded from it, or a window whose baseline is +// the initial done=0) reports a wildly optimistic ETA. This deliberately does NOT seed a zero +// baseline: the first tick becomes the baseline (absorbing the initial burst) and the rate is only +// measured between later, post-burst samples. It returns when ctx is cancelled (fetchBridges cancels +// it once g.Wait returns), stays quiet until at least one window completes, and never logs once +// everything is done (the caller logs the final summary). +func (s *BridgeSyncerLite) reportFetchProgress( + ctx context.Context, start time.Time, completed *atomic.Int64, total int64, fromBlock, toBlock uint64, +) { + ticker := time.NewTicker(progressLogInterval) + defer ticker.Stop() + + var samples []progressSample + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + samples = s.recordProgressTick(samples, time.Now(), start, completed.Load(), total, fromBlock, toBlock) + } + } +} + +// recordProgressTick handles a single progress tick: it appends the latest observation to samples, +// drops samples older than the trailing window (always keeping at least one as a baseline), and logs +// progress with an ETA extrapolated from the rate over the retained window. It returns the updated +// samples slice so the caller can carry it to the next tick. Nothing is logged until at least one +// window has completed, and once everything is done logging stops (the caller logs the final summary). +func (s *BridgeSyncerLite) recordProgressTick( + samples []progressSample, now, start time.Time, done, total int64, fromBlock, toBlock uint64, +) []progressSample { + // Record this observation and drop samples older than the trailing window, but always keep + // at least one so there is a baseline to measure the next tick against. The first tick has + // no prior sample, so it serves purely as the baseline (absorbing the initial burst). + samples = append(samples, progressSample{t: now, done: done}) + cutoff := now.Add(-etaRateWindow) + for len(samples) > 1 && samples[0].t.Before(cutoff) { + samples = samples[1:] + } + + if done == 0 || done >= total { + return samples + } + + // Rate over the retained window, measured against the oldest sample (never a synthetic + // done=0 at start). Unknown until we have a post-baseline sample with forward progress. + oldest := samples[0] + etaStr := "unknown" + if dt := now.Sub(oldest.t).Seconds(); dt > 0 && done > oldest.done { + rate := float64(done-oldest.done) / dt // windows/second + eta := time.Duration(float64(total-done) / rate * float64(time.Second)) + etaStr = eta.Truncate(time.Second).String() + } + s.log.Infof("fetching BridgeEvent logs [%d..%d]: %d/%d windows (%.1f%%), elapsed %s, ETA %s", + fromBlock, toBlock, done, total, float64(done)/float64(total)*percentMultiplier, + time.Since(start).Truncate(time.Second), etaStr) + return samples +} + +// fetchWindow reads all logs the bridge contract emitted in [from, to] (no topic filter, so a +// single query surfaces both BridgeEvents and any forbidden event), parses the BridgeEvents into +// leaves and aborts on the first forbidden event. +func (s *BridgeSyncerLite) fetchWindow(ctx context.Context, from, to uint64) ([]BridgeLeaf, error) { + logs, err := s.client.FilterLogs(ctx, ethereum.FilterQuery{ + FromBlock: new(big.Int).SetUint64(from), + ToBlock: new(big.Int).SetUint64(to), + Addresses: []common.Address{s.cfg.BridgeAddr}, + }) + if err != nil { + return nil, err + } + return classifyLogs(s.contract, logs, s.cfg.IgnoreUnsupportedL2Events, s.log) +} + +// classifyLogs turns a batch of bridge-contract logs into BridgeLeaves: BridgeEvents are parsed and +// kept, and every other event is ignored. A forbidden event aborts with an error unless +// ignoreUnsupported is set, in which case it is logged as a warning and skipped (the reconstructed +// tree may then be incorrect). logger may be nil when ignoreUnsupported is false. +func classifyLogs( + contract *agglayerbridge.Agglayerbridge, logs []types.Log, ignoreUnsupported bool, logger *log.Logger, +) ([]BridgeLeaf, error) { + out := make([]BridgeLeaf, 0, len(logs)) + for i := range logs { + l := logs[i] + if len(l.Topics) == 0 { + continue + } + topic := l.Topics[0] + if name, forbidden := forbiddenEventSignatures[topic]; forbidden { + if !ignoreUnsupported { + return nil, fmt.Errorf("unsupported %s event detected at block %d (tx %s, log index %d): "+ + "bridge state cannot be reconstructed from BridgeEvent logs alone", + name, l.BlockNumber, l.TxHash.Hex(), l.Index) + } + if logger != nil { + logger.Warnf("unsupported %s event detected at block %d (tx %s, log index %d); "+ + "ignoring it because ignoreUnsupportedL2Events is set — the reconstructed bridge "+ + "state and NewLocalExitRoot may be incorrect", + name, l.BlockNumber, l.TxHash.Hex(), l.Index) + } + continue + } + if topic != bridgeEventSignature { + // any other event from the bridge contract is irrelevant to the exit tree + continue + } + leaf, err := parseBridgeEvent(contract, l) + if err != nil { + return nil, err + } + out = append(out, leaf) + } + return out, nil +} + +// parseBridgeEvent decodes a BridgeEvent log into a BridgeLeaf using the contract binding. +func parseBridgeEvent(contract *agglayerbridge.Agglayerbridge, l types.Log) (BridgeLeaf, error) { + event, err := contract.ParseBridgeEvent(l) + if err != nil { + return BridgeLeaf{}, fmt.Errorf("parse BridgeEvent log (tx %s, log index %d): %w", l.TxHash.Hex(), l.Index, err) + } + return BridgeLeaf{ + BlockNum: l.BlockNumber, + BlockPos: uint64(l.Index), + LeafType: event.LeafType, + OriginNetwork: event.OriginNetwork, + OriginAddress: event.OriginAddress, + DestinationNetwork: event.DestinationNetwork, + DestinationAddress: event.DestinationAddress, + Amount: event.Amount, + Metadata: event.Metadata, + DepositCount: event.DepositCount, + TxHash: l.TxHash, + }, nil +} diff --git a/tools/exit_certificate/bridgesyncerlite/downloader_test.go b/tools/exit_certificate/bridgesyncerlite/downloader_test.go new file mode 100644 index 000000000..ab9f0efea --- /dev/null +++ b/tools/exit_certificate/bridgesyncerlite/downloader_test.go @@ -0,0 +1,231 @@ +package bridgesyncerlite + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" + + "github.com/0xPolygon/cdk-contracts-tooling/contracts/aggchain-multisig/agglayerbridge" + "github.com/agglayer/aggkit/log" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/require" +) + +// --- fake JSON-RPC server ------------------------------------------------------------------------ + +type rpcRequest struct { + JSONRPC string `json:"jsonrpc"` + ID json.RawMessage `json:"id"` + Method string `json:"method"` + Params []json.RawMessage `json:"params"` +} + +type rpcResponse struct { + JSONRPC string `json:"jsonrpc"` + ID json.RawMessage `json:"id"` + Result any `json:"result"` +} + +// newRPCServer spins up an httptest server that answers eth_blockNumber and eth_getLogs from the +// supplied closures. It handles both single and batched JSON-RPC requests so it works regardless of +// how go-ethereum frames the call. +func newRPCServer(t *testing.T, blockNumber func() uint64, getLogs func() []types.Log) *httptest.Server { + t.Helper() + answer := func(req rpcRequest) rpcResponse { + resp := rpcResponse{JSONRPC: "2.0", ID: req.ID} + switch req.Method { + case "eth_blockNumber": + resp.Result = hexutil.Uint64(blockNumber()) + case "eth_getLogs": + resp.Result = getLogs() + default: + resp.Result = nil + } + return resp + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + w.Header().Set("Content-Type", "application/json") + + trimmed := bytes.TrimSpace(body) + if len(trimmed) > 0 && trimmed[0] == '[' { + var reqs []rpcRequest + require.NoError(t, json.Unmarshal(trimmed, &reqs)) + resps := make([]rpcResponse, len(reqs)) + for i, req := range reqs { + resps[i] = answer(req) + } + require.NoError(t, json.NewEncoder(w).Encode(resps)) + return + } + var req rpcRequest + require.NoError(t, json.Unmarshal(trimmed, &req)) + require.NoError(t, json.NewEncoder(w).Encode(answer(req))) + })) + t.Cleanup(srv.Close) + return srv +} + +// packBridgeEventLog builds a types.Log carrying an ABI-encoded BridgeEvent payload, matching what +// the bridge contract emits on chain. +func packBridgeEventLog(t *testing.T, leaf BridgeLeaf) types.Log { + t.Helper() + abi, err := agglayerbridge.AgglayerbridgeMetaData.GetAbi() + require.NoError(t, err) + data, err := abi.Events["BridgeEvent"].Inputs.Pack( + leaf.LeafType, leaf.OriginNetwork, leaf.OriginAddress, leaf.DestinationNetwork, + leaf.DestinationAddress, leaf.Amount, leaf.Metadata, leaf.DepositCount, + ) + require.NoError(t, err) + return types.Log{ + Address: common.HexToAddress("0xbeef"), + Topics: []common.Hash{bridgeEventSignature}, + Data: data, + BlockNumber: leaf.BlockNum, + TxHash: leaf.TxHash, + Index: uint(leaf.BlockPos), + } +} + +// --- parseBridgeEvent / classifyLogs unit coverage ----------------------------------------------- + +func TestParseBridgeEvent(t *testing.T) { + contract, err := agglayerbridge.NewAgglayerbridge(common.Address{}, nil) + require.NoError(t, err) + + want := newTestLeaf(7) + logEntry := packBridgeEventLog(t, want) + got, err := parseBridgeEvent(contract, logEntry) + require.NoError(t, err) + + require.Equal(t, want.LeafType, got.LeafType) + require.Equal(t, want.OriginNetwork, got.OriginNetwork) + require.Equal(t, want.OriginAddress, got.OriginAddress) + require.Equal(t, want.DestinationNetwork, got.DestinationNetwork) + require.Equal(t, want.DestinationAddress, got.DestinationAddress) + require.Equal(t, want.Amount, got.Amount) + require.Equal(t, want.Metadata, got.Metadata) + require.Equal(t, want.DepositCount, got.DepositCount) + require.Equal(t, want.TxHash, got.TxHash) + require.Equal(t, want.BlockNum, got.BlockNum) + require.Equal(t, want.BlockPos, got.BlockPos) + require.Equal(t, want.Hash(), got.Hash()) +} + +func TestParseBridgeEventBadData(t *testing.T) { + contract, err := agglayerbridge.NewAgglayerbridge(common.Address{}, nil) + require.NoError(t, err) + bad := types.Log{Topics: []common.Hash{bridgeEventSignature}, Data: []byte{0x01, 0x02}} + _, err = parseBridgeEvent(contract, bad) + require.Error(t, err) +} + +// TestClassifyLogsParsesBridgeEvent covers the full classify→parse→append happy path with a real +// ABI-encoded BridgeEvent log. +func TestClassifyLogsParsesBridgeEvent(t *testing.T) { + contract, err := agglayerbridge.NewAgglayerbridge(common.Address{}, nil) + require.NoError(t, err) + + leaf := newTestLeaf(3) + logs := []types.Log{ + {Topics: []common.Hash{common.HexToHash("0xdeadbeef")}}, // unrelated → ignored + packBridgeEventLog(t, leaf), + } + out, err := classifyLogs(contract, logs, false, nil) + require.NoError(t, err) + require.Len(t, out, 1) + require.Equal(t, leaf.Hash(), out[0].Hash()) +} + +func TestString(t *testing.T) { + leaf := newTestLeaf(2) + require.Contains(t, leaf.String(), "BridgeLeaf{") + require.Contains(t, leaf.String(), leaf.OriginAddress.Hex()) + require.Contains(t, leaf.String(), leaf.Amount.String()) + + leaf.Amount = nil + require.Contains(t, leaf.String(), "Amount: nil") +} + +// TestRecordProgressTick exercises every branch of the per-tick logic that the `case <-ticker.C:` +// arm of reportFetchProgress runs, without waiting for a real 5s ticker. +func TestRecordProgressTick(t *testing.T) { + s := &BridgeSyncerLite{log: log.WithFields("module", "bridgesyncerlite-test")} + start := time.Now() + + t.Run("first tick is recorded as baseline", func(t *testing.T) { + now := start.Add(5 * time.Second) + got := s.recordProgressTick(nil, now, start, 3, 10, 0, 100) + require.Len(t, got, 1) + require.Equal(t, int64(3), got[0].done) + require.Equal(t, now, got[0].t) + }) + + t.Run("done==0 still records the sample but logs nothing", func(t *testing.T) { + now := start.Add(5 * time.Second) + got := s.recordProgressTick(nil, now, start, 0, 10, 0, 100) + require.Len(t, got, 1) + require.Equal(t, int64(0), got[0].done) + }) + + t.Run("done>=total records the sample but logs nothing", func(t *testing.T) { + now := start.Add(5 * time.Second) + got := s.recordProgressTick(nil, now, start, 10, 10, 0, 100) + require.Len(t, got, 1) + }) + + t.Run("ETA computed once a post-baseline sample shows progress", func(t *testing.T) { + baseline := progressSample{t: start, done: 2} + now := start.Add(10 * time.Second) + // 8 windows done in 10s vs baseline of 2 → rate 0.6/s, 2 remaining → ~3s ETA. + got := s.recordProgressTick([]progressSample{baseline}, now, start, 8, 10, 0, 100) + require.Len(t, got, 2) + require.Equal(t, baseline, got[0]) + require.Equal(t, int64(8), got[1].done) + }) + + t.Run("ETA unknown when there is no forward progress vs the oldest sample", func(t *testing.T) { + baseline := progressSample{t: start, done: 5} + now := start.Add(5 * time.Second) + got := s.recordProgressTick([]progressSample{baseline}, now, start, 5, 10, 0, 100) + require.Len(t, got, 2) + }) + + t.Run("samples older than the trailing window are dropped, keeping a baseline", func(t *testing.T) { + old := progressSample{t: start, done: 1} + recent := progressSample{t: start.Add(etaRateWindow), done: 4} + // now is well past the window, so `old` falls outside the cutoff and is trimmed. + now := start.Add(2 * etaRateWindow) + got := s.recordProgressTick([]progressSample{old, recent}, now, start, 7, 10, 0, 100) + require.Len(t, got, 2) + require.Equal(t, recent, got[0]) + require.Equal(t, int64(7), got[1].done) + }) +} + +func TestReportFetchProgressReturnsOnCancel(t *testing.T) { + s := &BridgeSyncerLite{log: log.WithFields("module", "bridgesyncerlite-test")} + ctx, cancel := context.WithCancel(context.Background()) + var completed atomic.Int64 + done := make(chan struct{}) + go func() { + s.reportFetchProgress(ctx, time.Now(), &completed, 10, 0, 100) + close(done) + }() + cancel() + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("reportFetchProgress did not return after cancel") + } +} diff --git a/tools/exit_certificate/bridgesyncerlite/migrations.go b/tools/exit_certificate/bridgesyncerlite/migrations.go new file mode 100644 index 000000000..982280b5c --- /dev/null +++ b/tools/exit_certificate/bridgesyncerlite/migrations.go @@ -0,0 +1,43 @@ +package bridgesyncerlite + +import ( + "github.com/agglayer/aggkit/db" + dbtypes "github.com/agglayer/aggkit/db/types" + treemigrations "github.com/agglayer/aggkit/tree/migrations" +) + +// bridgeTableMigration creates the single table this lite syncer persists: one row per BridgeEvent +// log, keyed by deposit_count (the contract's unique monotonic counter, which is also the exit-tree +// leaf index). Unlike the full bridgesync schema there is no block/claim/token_mapping table and no +// foreign keys — this syncer only ever stores bridge leaves and never tracks a sync checkpoint. +const bridgeTableMigration = ` +-- +migrate Down +DROP TABLE IF EXISTS bridge; + +-- +migrate Up +CREATE TABLE bridge ( + block_num INTEGER NOT NULL, + block_pos INTEGER NOT NULL, + leaf_type INTEGER NOT NULL, + origin_network INTEGER NOT NULL, + origin_address VARCHAR NOT NULL, + destination_network INTEGER NOT NULL, + destination_address VARCHAR NOT NULL, + amount TEXT NOT NULL, + metadata BLOB, + deposit_count INTEGER NOT NULL PRIMARY KEY, + tx_hash VARCHAR NOT NULL +); +` + +// getMigrations returns the bridge table migration followed by the shared tree migrations +// (creating the rht/root tables the AppendOnlyTree relies on). +func getMigrations() []dbtypes.Migration { + migs := []dbtypes.Migration{{ID: "bridgesyncerlite0001", SQL: bridgeTableMigration}} + return append(migs, treemigrations.Migrations...) +} + +// runMigrations creates the bridge and tree tables in the sqlite file at dbPath. +func runMigrations(dbPath string) error { + return db.RunMigrations(dbPath, getMigrations()) +} diff --git a/tools/exit_certificate/bridgesyncerlite/syncer.go b/tools/exit_certificate/bridgesyncerlite/syncer.go new file mode 100644 index 000000000..459bac058 --- /dev/null +++ b/tools/exit_certificate/bridgesyncerlite/syncer.go @@ -0,0 +1,368 @@ +package bridgesyncerlite + +import ( + "context" + "database/sql" + "errors" + "fmt" + "sort" + "time" + + "github.com/0xPolygon/cdk-contracts-tooling/contracts/aggchain-multisig/agglayerbridge" + "github.com/agglayer/aggkit/db" + "github.com/agglayer/aggkit/log" + "github.com/agglayer/aggkit/tree" + treetypes "github.com/agglayer/aggkit/tree/types" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/russross/meddler" +) + +const bridgeTableName = "bridge" + +// Read queries. Built once as compile-time constant expressions (no runtime string formatting) so the +// only interpolated token is the trusted bridgeTableName constant — never user input. +const ( + queryCountBridges = "SELECT COUNT(*) FROM " + bridgeTableName + queryMaxDepositCount = "SELECT MAX(deposit_count) FROM " + bridgeTableName + queryAllBridges = "SELECT * FROM " + bridgeTableName + " ORDER BY deposit_count ASC" +) + +// buildProgressLogInterval is how often StoreBridges/BuildTree report persist/tree-build progress +// with an ETA. +const buildProgressLogInterval = 15 * time.Second + +// newBuildProgress returns a progress function for a sequential phase over `total` items. Calling it +// with the number of items done so far logs (at most once per buildProgressLogInterval, plus a final +// line at done==total) the percentage, elapsed time and an ETA extrapolated from the average rate. +func (s *BridgeSyncerLite) newBuildProgress(phase string, total int) func(done int) { + start := time.Now() + lastLog := start + return func(done int) { + now := time.Now() + if done < total && now.Sub(lastLog) < buildProgressLogInterval { + return + } + lastLog = now + elapsed := now.Sub(start) + var eta time.Duration + if done > 0 && done < total { + eta = time.Duration(float64(elapsed) / float64(done) * float64(total-done)) + } + s.log.Infof("%s: %d/%d leaves (%.1f%%), elapsed %s, ETA %s", + phase, done, total, float64(done)/float64(total)*percentMultiplier, + elapsed.Truncate(time.Second), eta.Truncate(time.Second)) + } +} + +// BridgeSyncerLite is a minimal bridge syncer: it reads BridgeEvent logs (event data only, no +// calldata) from a chain, persists them to a sqlite DB and builds the bridge exit tree. It keeps no +// sync checkpoint, so it cannot resume — each Sync/AddBlocks call processes the block range it is +// given. The exit tree is byte-for-byte compatible with the canonical bridgesync exit tree. +type BridgeSyncerLite struct { + cfg Config + log *log.Logger + db *sql.DB + client *ethclient.Client + contract *agglayerbridge.Agglayerbridge + exitTree treetypes.FullTreer +} + +// New returns a ready-to-use syncer. When cfg.DBPath is set, the sqlite DB is created/migrated and +// the syncer can persist bridges (Sync, AddBlocks, StoreBridges) and build the exit tree +// (BuildTree). When cfg.RPCURL is set, the syncer dials it and can read bridges from the chain +// (FetchBridges, Sync, AddBlocks, LatestBlock). At least one of the two must be set: +// - DBPath only → DB-only mode: no chain access, only StoreBridges/BuildTree/GetBridges/ +// LocalExitRoot are available (useful to insert pre-collected bridges and build the tree +// without any RPC calls). +// - RPCURL only → fetch-only mode: no DB or tree, only FetchBridges/LatestBlock. +// - both → full mode. +// +// Call Close when done. +func New(ctx context.Context, cfg Config, logger *log.Logger) (*BridgeSyncerLite, error) { + if cfg.RPCURL == "" && cfg.DBPath == "" { + return nil, errors.New("at least one of RPCURL or DBPath is required") + } + if cfg.BlockChunkSize == 0 { + cfg.BlockChunkSize = defaultBlockChunkSize + } + if cfg.Concurrency == 0 { + cfg.Concurrency = defaultConcurrency + } + if logger == nil { + logger = log.WithFields("module", "bridgesyncerlite") + } + + database, exitTree, err := openDatabase(cfg.DBPath) + if err != nil { + return nil, err + } + + client, contract, err := dialBridge(ctx, cfg) + if err != nil { + if database != nil { + _ = database.Close() + } + return nil, err + } + + return &BridgeSyncerLite{ + cfg: cfg, + log: logger, + db: database, + client: client, + contract: contract, + exitTree: exitTree, + }, nil +} + +// openDatabase migrates and opens the sqlite DB at dbPath and creates the append-only exit tree. +// Returns (nil, nil, nil) when dbPath is empty (fetch-only mode, no DB or tree). +func openDatabase(dbPath string) (*sql.DB, treetypes.FullTreer, error) { + if dbPath == "" { + return nil, nil, nil + } + if err := runMigrations(dbPath); err != nil { + return nil, nil, fmt.Errorf("run migrations on %s: %w", dbPath, err) + } + database, err := db.NewSQLiteDB(dbPath) + if err != nil { + return nil, nil, fmt.Errorf("open sqlite DB %s: %w", dbPath, err) + } + return database, tree.NewAppendOnlyTree(database, ""), nil +} + +// dialBridge dials cfg.RPCURL and instantiates the bridge contract binding. Returns (nil, nil, nil) +// when cfg.RPCURL is empty (DB-only mode). On binding failure it closes the client it opened; the +// caller owns any other resources. +func dialBridge( + ctx context.Context, cfg Config, +) (*ethclient.Client, *agglayerbridge.Agglayerbridge, error) { + if cfg.RPCURL == "" { + return nil, nil, nil + } + client, err := ethclient.DialContext(ctx, cfg.RPCURL) + if err != nil { + return nil, nil, fmt.Errorf("dial RPC %s: %w", cfg.RPCURL, err) + } + contract, err := agglayerbridge.NewAgglayerbridge(cfg.BridgeAddr, client) + if err != nil { + client.Close() + return nil, nil, fmt.Errorf("instantiate bridge contract binding: %w", err) + } + return client, contract, nil +} + +// Close releases the RPC client (if any) and DB connection (if any). +func (s *BridgeSyncerLite) Close() error { + if s.client != nil { + s.client.Close() + } + if s.db != nil { + return s.db.Close() + } + return nil +} + +// LatestBlock returns the current head block of the connected chain. +func (s *BridgeSyncerLite) LatestBlock(ctx context.Context) (uint64, error) { + if s.client == nil { + return 0, errors.New("LatestBlock requires an RPC-backed syncer (set Config.RPCURL)") + } + return s.client.BlockNumber(ctx) +} + +// FetchBridges reads every BridgeEvent in [fromBlock, toBlock] (querying the range in parallel) and +// returns the leaves sorted by deposit count, without persisting them or touching the exit tree. It +// aborts if any forbidden event is present in the range. Use this to recover the on-chain deposit +// order of a block range; use Sync to also persist and build the tree. +func (s *BridgeSyncerLite) FetchBridges(ctx context.Context, fromBlock, toBlock uint64) ([]BridgeLeaf, error) { + bridges, err := s.fetchBridges(ctx, fromBlock, toBlock) + if err != nil { + return nil, err + } + sort.Slice(bridges, func(i, j int) bool { return bridges[i].DepositCount < bridges[j].DepositCount }) + return bridges, nil +} + +// Sync reads every BridgeEvent in [fromBlock, toBlock] (querying in parallel) and persists the +// leaves. It does NOT build the exit tree — call BuildTree once all bridges (across every Sync / +// AddBlocks call) are persisted. This is the initial full-history pass. +func (s *BridgeSyncerLite) Sync(ctx context.Context, fromBlock, toBlock uint64) error { + bridges, err := s.fetchBridges(ctx, fromBlock, toBlock) + if err != nil { + return err + } + return s.StoreBridges(ctx, bridges) +} + +// AddBlocks reads the BridgeEvents in [fromBlock, toBlock] and persists them. Like Sync it does not +// build the tree; it is meant for adding more logs after the initial Sync (e.g. the shadow-fork +// blocks) before a single BuildTree call assembles the whole tree. +func (s *BridgeSyncerLite) AddBlocks(ctx context.Context, fromBlock, toBlock uint64) error { + bridges, err := s.fetchBridges(ctx, fromBlock, toBlock) + if err != nil { + return err + } + return s.StoreBridges(ctx, bridges) +} + +// StoreBridges persists the given bridges (ordered by deposit count) in a single transaction. It +// does not touch the exit tree — building it is deferred to BuildTree, which runs once after all +// bridges are stored. +func (s *BridgeSyncerLite) StoreBridges(ctx context.Context, bridges []BridgeLeaf) error { + if s.db == nil { + return errors.New("StoreBridges requires a DB-backed syncer (set Config.DBPath)") + } + if len(bridges) == 0 { + s.log.Info("no bridges to store") + return nil + } + + sort.Slice(bridges, func(i, j int) bool { return bridges[i].DepositCount < bridges[j].DepositCount }) + + tx, err := db.NewTx(ctx, s.db) + if err != nil { + return fmt.Errorf("begin transaction: %w", err) + } + committed := false + defer func() { + if !committed { + if rerr := tx.Rollback(); rerr != nil { + s.log.Errorf("rollback failed: %v", rerr) + } + } + }() + + progress := s.newBuildProgress("persisting bridges", len(bridges)) + for i := range bridges { + if err := meddler.Insert(tx, bridgeTableName, &bridges[i]); err != nil { + return fmt.Errorf("insert bridge (deposit_count %d): %w", bridges[i].DepositCount, err) + } + progress(i + 1) + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit transaction: %w", err) + } + committed = true + s.log.Infof("stored %d bridges", len(bridges)) + return nil +} + +// BuildTree builds the exit tree from every persisted bridge, in deposit-count order, and returns +// the resulting local exit root. The tree must be empty (build it once after all bridges have been +// stored): the lowest deposit count must be 0 and the counts must be contiguous, or the build fails. +func (s *BridgeSyncerLite) BuildTree(ctx context.Context) (common.Hash, error) { + if s.db == nil || s.exitTree == nil { + return common.Hash{}, errors.New("BuildTree requires a DB-backed syncer (set Config.DBPath)") + } + + bridges, err := s.GetBridges(ctx) + if err != nil { + return common.Hash{}, err + } + if len(bridges) == 0 { + s.log.Info("no bridges stored; exit tree is empty") + return common.Hash{}, nil + } + + tx, err := db.NewTx(ctx, s.db) + if err != nil { + return common.Hash{}, fmt.Errorf("begin transaction: %w", err) + } + committed := false + defer func() { + if !committed { + if rerr := tx.Rollback(); rerr != nil { + s.log.Errorf("rollback failed: %v", rerr) + } + } + }() + + progress := s.newBuildProgress("building exit tree", len(bridges)) + for i := range bridges { + b := &bridges[i] + if _, err := s.exitTree.PutLeaf(tx, b.BlockNum, b.BlockPos, treetypes.Leaf{ + Index: b.DepositCount, + Hash: b.Hash(), + }); err != nil { + return common.Hash{}, fmt.Errorf("add leaf (deposit_count %d) to exit tree: %w", b.DepositCount, err) + } + progress(i + 1) + } + + if err := tx.Commit(); err != nil { + return common.Hash{}, fmt.Errorf("commit transaction: %w", err) + } + committed = true + + root, err := s.LocalExitRoot() + if err != nil { + return common.Hash{}, err + } + s.log.Infof("built exit tree from %d bridges; local exit root = %s", len(bridges), root.Hex()) + return root, nil +} + +// LocalExitRoot returns the current root of the exit tree, or the zero hash if the tree is empty. +func (s *BridgeSyncerLite) LocalExitRoot() (common.Hash, error) { + if s.exitTree == nil { + return common.Hash{}, errors.New("LocalExitRoot requires a DB-backed syncer (set Config.DBPath)") + } + root, err := s.exitTree.GetLastRoot(nil) + if err != nil { + if errors.Is(err, db.ErrNotFound) { + return common.Hash{}, nil + } + return common.Hash{}, fmt.Errorf("get last exit tree root: %w", err) + } + return root.Hash, nil +} + +// CountBridges returns the number of persisted bridge leaves. It runs a single COUNT(*) aggregate +// query rather than loading every bridge into memory, so it stays O(1) on mainnet-scale histories. +func (s *BridgeSyncerLite) CountBridges(ctx context.Context) (int, error) { + if s.db == nil { + return 0, errors.New("CountBridges requires a DB-backed syncer (set Config.DBPath)") + } + var count int + if err := s.db.QueryRowContext(ctx, queryCountBridges).Scan(&count); err != nil { + return 0, fmt.Errorf("count bridges: %w", err) + } + return count, nil +} + +// NextDepositCount returns the deposit count the next inserted bridge should get: one past the +// highest deposit count currently persisted, or 0 when the DB is empty. It runs a single aggregate +// query (MAX(deposit_count)) rather than loading every bridge into memory, so it stays O(1) on +// mainnet-scale histories. +func (s *BridgeSyncerLite) NextDepositCount(ctx context.Context) (uint32, error) { + if s.db == nil { + return 0, errors.New("NextDepositCount requires a DB-backed syncer (set Config.DBPath)") + } + var maxDepositCount sql.NullInt64 + if err := s.db.QueryRowContext(ctx, queryMaxDepositCount).Scan(&maxDepositCount); err != nil { + return 0, fmt.Errorf("query max deposit count: %w", err) + } + if !maxDepositCount.Valid { + return 0, nil + } + return uint32(maxDepositCount.Int64) + 1, nil +} + +// GetBridges returns all persisted bridge leaves ordered by deposit count. +func (s *BridgeSyncerLite) GetBridges(ctx context.Context) ([]BridgeLeaf, error) { + if s.db == nil { + return nil, errors.New("GetBridges requires a DB-backed syncer (set Config.DBPath)") + } + var ptrs []*BridgeLeaf + if err := meddler.QueryAll(s.db, &ptrs, queryAllBridges); err != nil { + return nil, fmt.Errorf("query bridges: %w", err) + } + bridges := make([]BridgeLeaf, len(ptrs)) + for i, p := range ptrs { + bridges[i] = *p + } + return bridges, nil +} diff --git a/tools/exit_certificate/bridgesyncerlite/syncer_rpc_test.go b/tools/exit_certificate/bridgesyncerlite/syncer_rpc_test.go new file mode 100644 index 000000000..d76b9f719 --- /dev/null +++ b/tools/exit_certificate/bridgesyncerlite/syncer_rpc_test.go @@ -0,0 +1,238 @@ +package bridgesyncerlite + +import ( + "context" + "path/filepath" + "testing" + + "github.com/agglayer/aggkit/log" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/require" +) + +func tempDBPath(t *testing.T) string { + t.Helper() + return filepath.Join(t.TempDir(), "lite.sqlite") +} + +func TestNewRequiresRPCorDB(t *testing.T) { + _, err := New(context.Background(), Config{}, nil) + require.Error(t, err) + require.Contains(t, err.Error(), "at least one of RPCURL or DBPath") +} + +func TestNewDBOnly(t *testing.T) { + s, err := New(context.Background(), Config{DBPath: tempDBPath(t)}, nil) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, s.Close()) }) + + // defaults applied + require.Equal(t, defaultBlockChunkSize, s.cfg.BlockChunkSize) + require.Equal(t, defaultConcurrency, s.cfg.Concurrency) + // DB-backed, no RPC client + require.NotNil(t, s.db) + require.Nil(t, s.client) + require.NotNil(t, s.exitTree) + + // RPC-only operations must fail without a client + _, err = s.LatestBlock(context.Background()) + require.Error(t, err) + _, err = s.FetchBridges(context.Background(), 0, 10) + require.Error(t, err) +} + +func TestNewFullMode(t *testing.T) { + srv := newRPCServer(t, func() uint64 { return 0 }, func() []types.Log { return nil }) + s, err := New(context.Background(), Config{ + DBPath: tempDBPath(t), + RPCURL: srv.URL, + BridgeAddr: common.HexToAddress("0xbeef"), + BlockChunkSize: 5, + Concurrency: 2, + }, log.WithFields("module", "test")) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, s.Close()) }) + + require.NotNil(t, s.db) + require.NotNil(t, s.client) + require.NotNil(t, s.contract) + require.NotNil(t, s.exitTree) +} + +func TestNewDialErrorClosesDB(t *testing.T) { + // An unsupported URL scheme makes ethclient.DialContext fail; with DBPath set this also exercises + // the database-cleanup branch in New. + _, err := New(context.Background(), Config{ + DBPath: tempDBPath(t), + RPCURL: "invalid-scheme://nowhere", + }, nil) + require.Error(t, err) + require.Contains(t, err.Error(), "dial RPC") +} + +func TestCloseDBOnly(t *testing.T) { + s, err := New(context.Background(), Config{DBPath: tempDBPath(t)}, nil) + require.NoError(t, err) + require.NoError(t, s.Close()) +} + +func TestCloseNoResources(t *testing.T) { + s := &BridgeSyncerLite{log: log.WithFields("module", "test")} + require.NoError(t, s.Close()) +} + +func TestLatestBlock(t *testing.T) { + srv := newRPCServer(t, func() uint64 { return 12345 }, func() []types.Log { return nil }) + s, err := New(context.Background(), Config{RPCURL: srv.URL, BridgeAddr: common.HexToAddress("0xbeef")}, nil) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, s.Close()) }) + + bn, err := s.LatestBlock(context.Background()) + require.NoError(t, err) + require.Equal(t, uint64(12345), bn) +} + +func TestLatestBlockNoClient(t *testing.T) { + s := &BridgeSyncerLite{log: log.WithFields("module", "test")} + _, err := s.LatestBlock(context.Background()) + require.Error(t, err) +} + +// TestFetchBridgesFromServer drives fetchBridges → fetchWindow → classifyLogs → parseBridgeEvent +// across multiple windows against the fake JSON-RPC server, and verifies FetchBridges returns the +// leaves sorted by deposit count. +func TestFetchBridgesFromServer(t *testing.T) { + want := []BridgeLeaf{newTestLeaf(2), newTestLeaf(0), newTestLeaf(1)} + logs := make([]types.Log, len(want)) + for i, l := range want { + logs[i] = packBridgeEventLog(t, l) + } + srv := newRPCServer(t, func() uint64 { return 100 }, func() []types.Log { return logs }) + + s, err := New(context.Background(), Config{ + RPCURL: srv.URL, + BridgeAddr: common.HexToAddress("0xbeef"), + BlockChunkSize: 10, + Concurrency: 3, + }, nil) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, s.Close()) }) + + // range spanning several windows so the parallel window loop runs + got, err := s.FetchBridges(context.Background(), 0, 100) + require.NoError(t, err) + require.NotEmpty(t, got) + // sorted by deposit count + for i := 1; i < len(got); i++ { + require.LessOrEqual(t, got[i-1].DepositCount, got[i].DepositCount) + } +} + +func TestFetchBridgesNoClient(t *testing.T) { + s := &BridgeSyncerLite{ + log: log.WithFields("module", "test"), + cfg: Config{BlockChunkSize: defaultBlockChunkSize, Concurrency: defaultConcurrency}, + } + _, err := s.FetchBridges(context.Background(), 0, 10) + require.Error(t, err) + require.Contains(t, err.Error(), "RPC-backed") +} + +func TestFetchBridgesInvalidRange(t *testing.T) { + srv := newRPCServer(t, func() uint64 { return 0 }, func() []types.Log { return nil }) + s, err := New(context.Background(), Config{RPCURL: srv.URL, BridgeAddr: common.HexToAddress("0xbeef")}, nil) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, s.Close()) }) + + _, err = s.FetchBridges(context.Background(), 10, 5) + require.Error(t, err) + require.Contains(t, err.Error(), "invalid block range") +} + +// TestSyncAndAddBlocks drives Sync and AddBlocks end-to-end: fetch from the fake server, persist to +// the DB, then build the tree and check the root is non-zero. +func TestSyncAndAddBlocks(t *testing.T) { + first := []BridgeLeaf{newTestLeaf(0), newTestLeaf(1)} + second := []BridgeLeaf{newTestLeaf(2), newTestLeaf(3)} + phase := 0 + srv := newRPCServer(t, func() uint64 { return 100 }, func() []types.Log { + var src []BridgeLeaf + if phase == 0 { + src = first + } else { + src = second + } + logs := make([]types.Log, len(src)) + for i, l := range src { + logs[i] = packBridgeEventLog(t, l) + } + return logs + }) + + s, err := New(context.Background(), Config{ + DBPath: tempDBPath(t), + RPCURL: srv.URL, + BridgeAddr: common.HexToAddress("0xbeef"), + BlockChunkSize: 100, + Concurrency: 1, + }, nil) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, s.Close()) }) + + ctx := context.Background() + require.NoError(t, s.Sync(ctx, 0, 50)) + phase = 1 + require.NoError(t, s.AddBlocks(ctx, 51, 100)) + + count, err := s.CountBridges(ctx) + require.NoError(t, err) + require.Equal(t, 4, count) + + root, err := s.BuildTree(ctx) + require.NoError(t, err) + require.NotEqual(t, common.Hash{}, root) +} + +func TestSyncNoClient(t *testing.T) { + s := &BridgeSyncerLite{ + log: log.WithFields("module", "test"), + cfg: Config{BlockChunkSize: defaultBlockChunkSize, Concurrency: defaultConcurrency}, + } + require.Error(t, s.Sync(context.Background(), 0, 10)) + require.Error(t, s.AddBlocks(context.Background(), 0, 10)) +} + +// --- DB-less error branches ---------------------------------------------------------------------- + +func TestDBOperationsRequireDB(t *testing.T) { + s := &BridgeSyncerLite{log: log.WithFields("module", "test")} + ctx := context.Background() + + require.Error(t, s.StoreBridges(ctx, []BridgeLeaf{newTestLeaf(0)})) + _, err := s.BuildTree(ctx) + require.Error(t, err) + _, err = s.LocalExitRoot() + require.Error(t, err) + _, err = s.CountBridges(ctx) + require.Error(t, err) + _, err = s.NextDepositCount(ctx) + require.Error(t, err) + _, err = s.GetBridges(ctx) + require.Error(t, err) +} + +func TestStoreBridgesEmpty(t *testing.T) { + s := newTestSyncer(t) + require.NoError(t, s.StoreBridges(context.Background(), nil)) + count, err := s.CountBridges(context.Background()) + require.NoError(t, err) + require.Equal(t, 0, count) +} + +func TestBuildTreeEmpty(t *testing.T) { + s := newTestSyncer(t) + root, err := s.BuildTree(context.Background()) + require.NoError(t, err) + require.Equal(t, common.Hash{}, root) +} diff --git a/tools/exit_certificate/bridgesyncerlite/syncer_test.go b/tools/exit_certificate/bridgesyncerlite/syncer_test.go new file mode 100644 index 000000000..f0004f4b7 --- /dev/null +++ b/tools/exit_certificate/bridgesyncerlite/syncer_test.go @@ -0,0 +1,249 @@ +package bridgesyncerlite + +import ( + "context" + "math/big" + "path/filepath" + "testing" + + "github.com/agglayer/aggkit/bridgesync" + "github.com/agglayer/aggkit/db" + "github.com/agglayer/aggkit/log" + "github.com/agglayer/aggkit/tree" + treetypes "github.com/agglayer/aggkit/tree/types" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/stretchr/testify/require" +) + +func newTestLeaf(depositCount uint32) BridgeLeaf { + return BridgeLeaf{ + BlockNum: uint64(100 + depositCount), + BlockPos: uint64(depositCount), + LeafType: uint8(depositCount % 2), + OriginNetwork: 0, + OriginAddress: common.BytesToAddress([]byte{byte(depositCount + 1)}), + DestinationNetwork: 1, + DestinationAddress: common.BytesToAddress([]byte{byte(depositCount + 2)}), + Amount: big.NewInt(int64(depositCount) * 1000), + Metadata: []byte{byte(depositCount)}, + DepositCount: depositCount, + TxHash: common.BytesToHash([]byte{byte(depositCount)}), + } +} + +// TestHashMatchesBridgesync guarantees the lite leaf hash is byte-for-byte identical to the +// canonical bridgesync.Bridge.Hash, so the tree this syncer builds matches the real exit tree. +func TestHashMatchesBridgesync(t *testing.T) { + for dc := uint32(0); dc < 5; dc++ { //nolint:intrange // uint32 counter + leaf := newTestLeaf(dc) + ref := bridgesync.Bridge{ + LeafType: leaf.LeafType, + OriginNetwork: leaf.OriginNetwork, + OriginAddress: leaf.OriginAddress, + DestinationNetwork: leaf.DestinationNetwork, + DestinationAddress: leaf.DestinationAddress, + Amount: new(big.Int).Set(leaf.Amount), + Metadata: leaf.Metadata, + DepositCount: leaf.DepositCount, + } + require.Equal(t, ref.Hash(), leaf.Hash(), "deposit count %d", dc) + } + + // nil amount must be treated as zero (same as bridgesync) + leaf := newTestLeaf(0) + leaf.Amount = nil + ref := bridgesync.Bridge{ + LeafType: leaf.LeafType, + OriginNetwork: leaf.OriginNetwork, + OriginAddress: leaf.OriginAddress, + DestinationNetwork: leaf.DestinationNetwork, + DestinationAddress: leaf.DestinationAddress, + Amount: nil, + Metadata: leaf.Metadata, + DepositCount: leaf.DepositCount, + } + require.Equal(t, ref.Hash(), leaf.Hash()) +} + +func newTestSyncer(t *testing.T) *BridgeSyncerLite { + t.Helper() + dbPath := filepath.Join(t.TempDir(), "lite.sqlite") + require.NoError(t, runMigrations(dbPath)) + database, err := db.NewSQLiteDB(dbPath) + require.NoError(t, err) + t.Cleanup(func() { database.Close() }) + return &BridgeSyncerLite{ + cfg: Config{BlockChunkSize: defaultBlockChunkSize, Concurrency: defaultConcurrency}, + log: log.WithFields("module", "bridgesyncerlite-test"), + db: database, + exitTree: tree.NewAppendOnlyTree(database, ""), + } +} + +// referenceRoot builds an independent AppendOnlyTree and inserts the leaves in deposit-count order, +// returning the resulting root — the value BuildTree must reproduce. +func referenceRoot(t *testing.T, leaves []BridgeLeaf) common.Hash { + t.Helper() + dbPath := filepath.Join(t.TempDir(), "ref.sqlite") + require.NoError(t, runMigrations(dbPath)) + database, err := db.NewSQLiteDB(dbPath) + require.NoError(t, err) + defer database.Close() + + refTree := tree.NewAppendOnlyTree(database, "") + tx, err := db.NewTx(context.Background(), database) + require.NoError(t, err) + for i := uint32(0); i < uint32(len(leaves)); i++ { + var leaf BridgeLeaf + for _, l := range leaves { + if l.DepositCount == i { + leaf = l + break + } + } + _, err := refTree.PutLeaf(tx, leaf.BlockNum, leaf.BlockPos, treetypes.Leaf{ + Index: leaf.DepositCount, + Hash: leaf.Hash(), + }) + require.NoError(t, err) + } + require.NoError(t, tx.Commit()) + root, err := refTree.GetLastRoot(database) + require.NoError(t, err) + return root.Hash +} + +func TestStoreBridgesAndBuildTree(t *testing.T) { + s := newTestSyncer(t) + ctx := context.Background() + + // empty tree → zero root + root, err := s.LocalExitRoot() + require.NoError(t, err) + require.Equal(t, common.Hash{}, root) + + // store leaves out of deposit-count order, across two calls (genesis→fork + shadow-fork); + // StoreBridges must sort them and BuildTree must assemble the whole tree once. + leaves := []BridgeLeaf{ + newTestLeaf(2), newTestLeaf(0), newTestLeaf(4), newTestLeaf(1), newTestLeaf(3), + } + require.NoError(t, s.StoreBridges(ctx, leaves)) + + more := []BridgeLeaf{newTestLeaf(6), newTestLeaf(5)} + require.NoError(t, s.StoreBridges(ctx, more)) + + // tree not built yet → still zero root + root, err = s.LocalExitRoot() + require.NoError(t, err) + require.Equal(t, common.Hash{}, root) + + // GetBridges returns all stored leaves ordered by deposit count + all := append(append([]BridgeLeaf{}, leaves...), more...) + stored, err := s.GetBridges(ctx) + require.NoError(t, err) + require.Len(t, stored, len(all)) + for i, b := range stored { + require.Equal(t, uint32(i), b.DepositCount) + } + + // build the whole tree once; its root must match an independently built reference tree + root, err = s.BuildTree(ctx) + require.NoError(t, err) + require.Equal(t, referenceRoot(t, all), root) + + // LocalExitRoot now reflects the built tree + ler, err := s.LocalExitRoot() + require.NoError(t, err) + require.Equal(t, root, ler) +} + +func TestNextDepositCount(t *testing.T) { + s := newTestSyncer(t) + ctx := context.Background() + + // empty DB → next deposit count is 0 + next, err := s.NextDepositCount(ctx) + require.NoError(t, err) + require.Equal(t, uint32(0), next) + + // store leaves 0..4 (out of order) → next is max(deposit_count)+1 = 5 + require.NoError(t, s.StoreBridges(ctx, []BridgeLeaf{ + newTestLeaf(2), newTestLeaf(0), newTestLeaf(4), newTestLeaf(1), newTestLeaf(3), + })) + next, err = s.NextDepositCount(ctx) + require.NoError(t, err) + require.Equal(t, uint32(5), next) +} + +func TestCountBridges(t *testing.T) { + s := newTestSyncer(t) + ctx := context.Background() + + // empty DB → 0 + count, err := s.CountBridges(ctx) + require.NoError(t, err) + require.Equal(t, 0, count) + + // store 5 leaves → 5 + require.NoError(t, s.StoreBridges(ctx, []BridgeLeaf{ + newTestLeaf(0), newTestLeaf(1), newTestLeaf(2), newTestLeaf(3), newTestLeaf(4), + })) + count, err = s.CountBridges(ctx) + require.NoError(t, err) + require.Equal(t, 5, count) +} + +func TestBuildTreeNonContiguousFails(t *testing.T) { + s := newTestSyncer(t) + // missing deposit count 1 → tree build must fail with invalid index + require.NoError(t, s.StoreBridges(context.Background(), []BridgeLeaf{newTestLeaf(0), newTestLeaf(2)})) + _, err := s.BuildTree(context.Background()) + require.Error(t, err) +} + +func TestClassifyLogsForbiddenEvents(t *testing.T) { + for topic, name := range forbiddenEventSignatures { + logs := []types.Log{{ + Topics: []common.Hash{topic}, + BlockNumber: 42, + TxHash: common.HexToHash("0xabc"), + }} + _, err := classifyLogs(nil, logs, false, nil) + require.Error(t, err, "event %s should be rejected", name) + require.Contains(t, err.Error(), name) + } +} + +// TestClassifyLogsIgnoreUnsupported verifies that with ignoreUnsupported set, a forbidden event is +// skipped (logged as a warning) instead of aborting the classification. +func TestClassifyLogsIgnoreUnsupported(t *testing.T) { + logger := log.WithFields("module", "bridgesyncerlite-test") + for topic, name := range forbiddenEventSignatures { + logs := []types.Log{{ + Topics: []common.Hash{topic}, + BlockNumber: 42, + TxHash: common.HexToHash("0xabc"), + }} + out, err := classifyLogs(nil, logs, true, logger) + require.NoError(t, err, "event %s should be allowed", name) + require.Empty(t, out, "forbidden event %s must not produce a leaf", name) + } +} + +func TestClassifyLogsIgnoresUnrelated(t *testing.T) { + // NewWrappedToken is intentionally NOT forbidden: it is neither indexed nor processed, so it must + // be ignored like any other unrelated event rather than aborting the sync. + newWrappedToken := crypto.Keccak256Hash([]byte("NewWrappedToken(uint32,address,address,bytes)")) + require.NotContains(t, forbiddenEventSignatures, newWrappedToken) + + logs := []types.Log{ + {Topics: []common.Hash{newWrappedToken}}, // NewWrappedToken — ignored, not an error + {Topics: []common.Hash{common.HexToHash("0xdeadbeef")}}, // unrelated event + {Topics: nil}, // anonymous / no topics + } + out, err := classifyLogs(nil, logs, false, nil) + require.NoError(t, err) + require.Empty(t, out) +} diff --git a/tools/exit_certificate/bridgesyncerlite/types.go b/tools/exit_certificate/bridgesyncerlite/types.go new file mode 100644 index 000000000..9d1867b08 --- /dev/null +++ b/tools/exit_certificate/bridgesyncerlite/types.go @@ -0,0 +1,95 @@ +package bridgesyncerlite + +import ( + "encoding/binary" + "fmt" + "math/big" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" +) + +const ( + // defaultBlockChunkSize is the number of blocks each parallel eth_getLogs query spans. + defaultBlockChunkSize = uint64(10000) + // defaultConcurrency is the number of eth_getLogs queries dispatched in parallel. + defaultConcurrency = 10 +) + +// Config configures a BridgeSyncerLite instance. +type Config struct { + // RPCURL is the JSON-RPC endpoint of the chain to read BridgeEvent logs from. + RPCURL string + // BridgeAddr is the address of the bridge contract whose logs are scanned. + BridgeAddr common.Address + // DBPath is the sqlite file storing bridge leaves and the exit tree. + DBPath string + // BlockChunkSize is the block span of each parallel eth_getLogs query (0 → defaultBlockChunkSize). + BlockChunkSize uint64 + // Concurrency is the number of eth_getLogs queries run in parallel (0 → defaultConcurrency). + Concurrency int + // IgnoreUnsupportedL2Events downgrades the abort-on-forbidden-event behaviour to a warning: events + // that would invalidate a BridgeEvent-only reconstruction (SetSovereignTokenAddress, + // MigrateLegacyToken, RemoveLegacySovereignTokenAddress, BackwardLET, ForwardLET) are logged and + // skipped instead of aborting the sync. The reconstructed tree / local exit root may then be + // incorrect, so enable this only knowingly (e.g. to inspect a chain that emitted such an event). + IgnoreUnsupportedL2Events bool +} + +// BridgeLeaf is a single BridgeEvent log persisted by the lite syncer. It carries only the data +// available in the event itself — no calldata, no tx sender, no from-address tracing. +type BridgeLeaf struct { + BlockNum uint64 `meddler:"block_num"` + BlockPos uint64 `meddler:"block_pos"` + LeafType uint8 `meddler:"leaf_type"` + OriginNetwork uint32 `meddler:"origin_network"` + OriginAddress common.Address `meddler:"origin_address,address"` + DestinationNetwork uint32 `meddler:"destination_network"` + DestinationAddress common.Address `meddler:"destination_address,address"` + Amount *big.Int `meddler:"amount,bigint"` + Metadata []byte `meddler:"metadata"` + DepositCount uint32 `meddler:"deposit_count"` + TxHash common.Hash `meddler:"tx_hash,hash"` +} + +// Hash returns the exit-tree leaf hash of the bridge event. It is byte-for-byte identical to +// bridgesync.Bridge.Hash so the tree this syncer builds matches the canonical bridge exit tree. +func (b *BridgeLeaf) Hash() common.Hash { + const ( + uint32ByteSize = 4 + bigIntSize = 32 + ) + origNet := make([]byte, uint32ByteSize) + binary.BigEndian.PutUint32(origNet, b.OriginNetwork) + destNet := make([]byte, uint32ByteSize) + binary.BigEndian.PutUint32(destNet, b.DestinationNetwork) + + metaHash := crypto.Keccak256(b.Metadata) + var buf [bigIntSize]byte + amount := b.Amount + if amount == nil { + amount = new(big.Int) + } + + return crypto.Keccak256Hash( + []byte{b.LeafType}, + origNet, + b.OriginAddress[:], + destNet, + b.DestinationAddress[:], + amount.FillBytes(buf[:]), + metaHash, + ) +} + +func (b *BridgeLeaf) String() string { + amountStr := "nil" + if b.Amount != nil { + amountStr = b.Amount.String() + } + return fmt.Sprintf("BridgeLeaf{BlockNum: %d, BlockPos: %d, LeafType: %d, OriginNetwork: %d, "+ + "OriginAddress: %s, DestinationNetwork: %d, DestinationAddress: %s, Amount: %s, "+ + "DepositCount: %d, TxHash: %s}", + b.BlockNum, b.BlockPos, b.LeafType, b.OriginNetwork, b.OriginAddress.Hex(), + b.DestinationNetwork, b.DestinationAddress.Hex(), amountStr, b.DepositCount, b.TxHash.Hex()) +} diff --git a/tools/exit_certificate/cmd/main.go b/tools/exit_certificate/cmd/main.go new file mode 100644 index 000000000..5c22ee19a --- /dev/null +++ b/tools/exit_certificate/cmd/main.go @@ -0,0 +1,81 @@ +package main + +import ( + "fmt" + "os" + + aggkit "github.com/agglayer/aggkit" + exit_certificate "github.com/agglayer/aggkit/tools/exit_certificate" + "github.com/urfave/cli/v2" +) + +func main() { + app := cli.NewApp() + app.Name = "exit-certificate" + app.Usage = "Generate exit certificates for zkEVM chain migration" + app.Version = aggkit.Version + app.Description = `Builds an exit certificate by running a multi-step pipeline against an L2 chain. + +Pipeline steps (run in order by default): + + 0 Generate the Locked Balance Table (LBT) by scanning the L2 bridge contract + for wrapped token mappings. + + A Collect all unique sender/receiver addresses from bridge events up to the + target block. + + B Scan EOA native-token balances and wrapped-token balances for every address + found in step A. + + C Scan smart-contract locked values using the LBT from step 0. + + D Aggregate step B and C results into a draft exit certificate. + + E Cross-check the draft certificate against L1 to filter out bridge exits that + have already been claimed. Skipped when l1RpcUrl is not set in the config. + + F Verify agglayer token balances against the certificate exits. + + G Calculate NewLocalExitRoot from the certificate bridge exits. + + H Fetch PreviousLocalExitRoot from the agglayer via interop_getNetworkInfo. + Requires agglayerRpcUrl in options. + + I Assemble the final certificate by writing NewLocalExitRoot (from G) and + PreviousLocalExitRoot (from H) into exit-certificate-final.json. + + SIGN Sign the final certificate with the configured keystore. + + SUBMIT Send the signed certificate to the agglayer via gRPC. + Requires agglayerClient.grpc.url in options. Not part of the default pipeline. + + WAIT Poll the agglayer every 5 seconds until the submitted certificate is + settled or enters an error state. Reads step-submit-result.json for + the certificate hash. Requires agglayerClient.grpc.url in options. + +Use --step to run a single step (e.g. --step a). When running steps individually +the output files from previous steps must already exist in the output directory.` + app.Flags = []cli.Flag{ + &cli.StringFlag{ + Name: "config", + Aliases: []string{"c"}, + Usage: "Path to the config file (JSON or TOML; format selected by .json/.toml extension)", + Value: "parameters.json", + }, + &cli.StringFlag{ + Name: "step", + Usage: "Run a specific step: 0, a, b, c, d, e, f, g, sign, or all", + Value: "all", + }, + &cli.BoolFlag{ + Name: "verbose", + Usage: "Enable debug logging", + }, + } + app.Action = exit_certificate.Run + + if err := app.Run(os.Args); err != nil { + _, _ = fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } +} diff --git a/tools/exit_certificate/config-examples/README.md b/tools/exit_certificate/config-examples/README.md new file mode 100644 index 000000000..60560b5f2 --- /dev/null +++ b/tools/exit_certificate/config-examples/README.md @@ -0,0 +1,16 @@ +# Example configurations + +This directory contains ready-to-use config files (TOML) for known networks. Copy the one that matches your chain, then fill in the fields listed below before running the tool. (The tool also accepts JSON — the format is selected by the `.toml`/`.json` file extension.) + +## Fields you must change + +| Field | Why | +| ----- | --- | +| `l1RpcUrl` | Your L1 JSON-RPC endpoint. Required by Step E and Step I — without it the certificate will be incomplete. Use a **Sepolia** RPC for `zkevm-cardona.toml` and an **Ethereum mainnet** RPC for `zkevm-mainnet.toml`. | +| `exitAddress` | The address that will receive assets locked in smart contracts. **Required** — and it **must not be the zero address** (`0x00…00`); the tool errors otherwise. **You must hold the private key for this address** — funds can only be recovered by signing from it after the certificate settles. **A multisig (e.g. a Gnosis Safe) is strongly recommended** instead of a single EOA, to avoid relying on one private key. | +| `l1GlobalExitRootAddress` | Address of `PolygonZkEVMGlobalExitRootV2` on L1. Required by Step I to fetch `L1InfoTreeLeafCount`. Replace the `` placeholder. | +| `options.agglayerClient.GRPC.URL` | Agglayer gRPC endpoint. Required for Steps H (PreviousLocalExitRoot), SUBMIT, and WAIT. Replace `` with the actual address, e.g. `"agglayer.example.com:50051"`. | +| `signerConfig` | Private key / KMS configuration used to sign the certificate in Step SIGN. | +| `options.agglayerAdminURL` / `agglayerAdminToken` | Agglayer admin RPC and (when behind Google Cloud IAP) its Bearer token. Required for Step F. Replace the `` / `` placeholders. To skip Step F when no admin endpoint is available, set `options.useAgglayerAdminToStepFCheck = false`. | + +Every field in the example files is annotated with an inline comment describing it, whether it is required, and its default. For a full description of every config field and all supported signer backends (local keystore, GCP KMS, AWS KMS, …) see the [main README](../README.md). diff --git a/tools/exit_certificate/config-examples/zkevm-cardona.toml b/tools/exit_certificate/config-examples/zkevm-cardona.toml new file mode 100644 index 000000000..957154b59 --- /dev/null +++ b/tools/exit_certificate/config-examples/zkevm-cardona.toml @@ -0,0 +1,77 @@ +# Example config for Polygon zkEVM Cardona (testnet). Copy to a .toml file and fill in the fields +# marked below. Field names are identical in the JSON format; format is chosen by file extension. +# See ./README.md and ../README.md for a full description of every field. + +# REQUIRED — L1 JSON-RPC endpoint (Steps E and I). Use a *Sepolia* RPC for Cardona. Without it +# Step E is silently skipped and Step I fails, so the certificate will be incomplete. +l1RpcUrl = "" + +# REQUIRED — L2 JSON-RPC endpoint. Must expose debug_traceTransaction (archive node) for Step A. +l2RpcUrl = "https://rpc-debug.cardona.zkevm-rpc.com/" + +# Optional — L1 bridge contract address. Defaults to l2BridgeAddress when unset. +l1BridgeAddress = "0x528e26b25a34a4A5d0dbDa1d57D318153d2ED582" + +# REQUIRED — L2 bridge contract address. +l2BridgeAddress = "0x528e26b25a34a4A5d0dbDa1d57D318153d2ED582" +l2NetworkId = 1 +targetBlock = "LatestBlock" + +# REQUIRED — address that receives SC-locked value (tokens held in smart contracts). Use an address +# whose private key you control; settled funds can only be recovered by signing from it. Must not be +# the zero address. A multisig (e.g. a Gnosis Safe) is strongly recommended over a single EOA for +# better security. Uncomment and set your own address before running. +#exitAddress = "0x0000000000000000000000000000000000001234" +sovereignRollupAddr = "0xA13Ddb14437A8F34897131367ad3ca78416d6bCa" +l1GlobalExitRootAddress = "" +# OPTIONAL — PolygonRollupManager on Sepolia (Cardona), used by Step WAIT to confirm L1 settlement. +# If omitted it is resolved on-chain from sovereignRollupAddr.rollupManager(). +rollupManagerAddress = "0x32d33D5137a7cFFb54c5Bf8371172bcEc5f310ff" +destinationNetwork = 0 + +# REQUIRED to sign — signer for Step SIGN (same format as aggsender's AggsenderPrivateKey). Other +# backends (GCP KMS, AWS KMS, …) are supported; see the go_signer repo. +[signerConfig] +Method = "local" +Path = "signer.keystore" +Password = "" + +[options] +blockRange = 10000 +stepAWindowSize = 10000 +concurrencyLimit = 10 +rpcBatchSize = 99 +rpcDelayMs = 10 +outputDir = "./output-cardona" +l1StartBlock = 5157692 +l2StartBlock = 0 +ignoreOnTraceError = false +ignoreGenesisBalance = false +ignoreUnclaimed = false +ignoreBalanceMismatch = false +extraErc20Contracts = [] + +# Base URL of the bridge service REST API. When set, Step E cross-checks unclaimed deposits and +# errors on discrepancies. Default: "" (disabled). +bridgeServiceURL = "https://bridge-api.cardona.zkevm-rpc.com" + +# Bridge service API flavour for the cross-check: "aggkit" or "zkevm". Default: "aggkit". +bridgeServiceType = "zkevm" + +# When true (default), Step F runs the agglayer admin balance check (three-way: LBT == agglayer == +# certificate). When false, it skips the agglayer query and compares LBT (step 0) vs certificate sums +# offline (no agglayerAdminURL needed; skipped only if no LBT data exists). +useAgglayerAdminToStepFCheck = true + +# REQUIRED for Step F in agglayer mode — agglayer admin RPC endpoint (admin_getTokenBalance). Step F +# errors if it runs in agglayer mode without this set. Not needed when useAgglayerAdminToStepFCheck = false. +agglayerAdminURL = "" +# REQUIRED for Step F when the admin endpoint is behind Google Cloud IAP — Bearer token for +# agglayerAdminURL (see the "Authenticating with IAP" section of the README). +agglayerAdminToken = "" + +# REQUIRED for Steps H, SUBMIT and WAIT — agglayer gRPC client config (same as aggsender's +# agglayer.ClientConfig). Set at least GRPC.URL (e.g. "agglayer.example.com:50051"). +[options.agglayerClient.GRPC] +URL = "" +UseTLS = true diff --git a/tools/exit_certificate/config-examples/zkevm-mainnet.toml b/tools/exit_certificate/config-examples/zkevm-mainnet.toml new file mode 100644 index 000000000..5c6dcfea9 --- /dev/null +++ b/tools/exit_certificate/config-examples/zkevm-mainnet.toml @@ -0,0 +1,77 @@ +# Example config for Polygon zkEVM mainnet. Copy to a .toml file and fill in the fields marked +# below. Field names are identical in the JSON format; format is chosen by file extension. +# See ./README.md and ../README.md for a full description of every field. + +# REQUIRED — L1 JSON-RPC endpoint (Steps E and I). Use an *Ethereum mainnet* RPC. Without it +# Step E is silently skipped and Step I fails, so the certificate will be incomplete. +l1RpcUrl = "" + +# REQUIRED — L2 JSON-RPC endpoint. Must expose debug_traceTransaction (archive node) for Step A. +l2RpcUrl = "https://zkevm-rpc.com/" + +# Optional — L1 bridge contract address. Defaults to l2BridgeAddress when unset. +l1BridgeAddress = "0x2a3DD3EB832aF982ec71669E178424b10Dca2EDe" + +# REQUIRED — L2 bridge contract address. +l2BridgeAddress = "0x2a3DD3EB832aF982ec71669E178424b10Dca2EDe" +l2NetworkId = 1 +targetBlock = "LatestBlock" + +# REQUIRED — address that receives SC-locked value (tokens held in smart contracts). Use an address +# whose private key you control; settled funds can only be recovered by signing from it. Must not be +# the zero address. A multisig (e.g. a Gnosis Safe) is strongly recommended over a single EOA for +# better security. Uncomment and set your own address before running. +#exitAddress = "" +sovereignRollupAddr = "0x519E42c24163192Dca44CD3fBDCEBF6be9130987" +l1GlobalExitRootAddress = "" +# OPTIONAL — PolygonRollupManager on Ethereum mainnet, used by Step WAIT to confirm L1 settlement. +# If omitted it is resolved on-chain from sovereignRollupAddr.rollupManager(). +rollupManagerAddress = "0x5132A183E9F3CB7C848b0AAC5Ae0c4f0491B7aB2" +destinationNetwork = 0 + +# REQUIRED to sign — signer for Step SIGN (same format as aggsender's AggsenderPrivateKey). Other +# backends (GCP KMS, AWS KMS, …) are supported; see the go_signer repo. +[signerConfig] +Method = "local" +Path = "signer.keystore" +Password = "" + +[options] +blockRange = 10000 +stepAWindowSize = 20000 +concurrencyLimit = 200 +rpcBatchSize = 99 +rpcDelayMs = 10 +outputDir = "./output-mainnet" +l1StartBlock = 22431675 +l2StartBlock = 0 +ignoreOnTraceError = false +ignoreGenesisBalance = false +ignoreUnclaimed = false +ignoreBalanceMismatch = false +extraErc20Contracts = ["0x4F9A0e7FD2Bf6067db6994CF12E4495Df938E6e9"] + +# Base URL of the bridge service REST API. When set, Step E cross-checks unclaimed deposits and +# errors on discrepancies. Default: "" (disabled). +bridgeServiceURL = "https://bridge-api.zkevm-rpc.com" + +# Bridge service API flavour for the cross-check: "aggkit" or "zkevm". Default: "aggkit". +bridgeServiceType = "zkevm" + +# When true (default), Step F runs the agglayer admin balance check (three-way: LBT == agglayer == +# certificate). When false, it skips the agglayer query and compares LBT (step 0) vs certificate sums +# offline (no agglayerAdminURL needed; skipped only if no LBT data exists). +useAgglayerAdminToStepFCheck = true + +# REQUIRED for Step F in agglayer mode — agglayer admin RPC endpoint (admin_getTokenBalance). Step F +# errors if it runs in agglayer mode without this set. Not needed when useAgglayerAdminToStepFCheck = false. +agglayerAdminURL = "" +# REQUIRED for Step F when the admin endpoint is behind Google Cloud IAP — Bearer token for +# agglayerAdminURL (see the "Authenticating with IAP" section of the README). +agglayerAdminToken = "" + +# REQUIRED for Steps H, SUBMIT and WAIT — agglayer gRPC client config (same as aggsender's +# agglayer.ClientConfig). Set at least GRPC.URL (e.g. "agglayer.example.com:50051"). +[options.agglayerClient.GRPC] +URL = "" +UseTLS = true diff --git a/tools/exit_certificate/config.go b/tools/exit_certificate/config.go new file mode 100644 index 000000000..0742a276e --- /dev/null +++ b/tools/exit_certificate/config.go @@ -0,0 +1,549 @@ +package exit_certificate + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/agglayer/aggkit/agglayer" + aggkitgrpc "github.com/agglayer/aggkit/grpc" + aggkittypes "github.com/agglayer/aggkit/types" + signertypes "github.com/agglayer/go_signer/signer/types" + "github.com/ethereum/go-ethereum/common" + "github.com/pelletier/go-toml/v2" +) + +// Options holds tuning parameters for RPC parallelism and output. +type Options struct { + BlockRange int `json:"blockRange"` + // StepAWindowSize is the number of blocks loaded into memory at once during Step A + // (address collection via debug_traceTransaction). Defaults to 5000, independently of BlockRange. + // Tune independently when trace calls need a different chunk size than log queries. + StepAWindowSize int `json:"stepAWindowSize"` + ConcurrencyLimit int `json:"concurrencyLimit"` + RPCBatchSize int `json:"rpcBatchSize"` + RPCDelayMs int `json:"rpcDelayMs"` + OutputDir string `json:"outputDir"` + L1StartBlock uint64 `json:"l1StartBlock"` + L2StartBlock uint64 `json:"l2StartBlock"` + AgglayerAdminURL string `json:"agglayerAdminURL"` + // AgglayerAdminToken is an optional Bearer token for authenticating requests to agglayerAdminURL. + // Required when the admin endpoint is protected by Google Cloud IAP. + // Obtain it with: gcloud auth print-identity-token --impersonate-service-account= + // --audiences= --include-email + AgglayerAdminToken string `json:"agglayerAdminToken"` + AgglayerClient agglayer.ClientConfig `json:"agglayerClient"` + // UseAgglayerAdminToStepFCheck, when true (the default), runs Step F: it queries the agglayer + // admin API (admin_getTokenBalance) and verifies the per-token balances against the certificate + // and LBT. When false, Step F is skipped entirely (no agglayer admin query, no balance check). + UseAgglayerAdminToStepFCheck bool `json:"useAgglayerAdminToStepFCheck"` + // IgnoreGenesisBalance, when true, suppresses the abort that fires when any EOA or contract has a + // non-zero ETH balance at block 0 (a genesis preload that would inflate the exit certificate + // totals): the check still runs and warns, but the run continues. Defaults to false (abort); set + // to true only for Kurtosis or test environments. + IgnoreGenesisBalance bool `json:"ignoreGenesisBalance"` + // IgnoreOnTraceError skips transactions whose debug_traceTransaction call fails instead of + // aborting Step A. Failed tx hashes are saved to step-a-failed-traces.json for review. + IgnoreOnTraceError bool `json:"ignoreOnTraceError"` + // IgnoreBalanceMismatch suppresses the error returned by Step F when token balances + // do not match. Set to true only when investigating discrepancies without blocking the pipeline. + IgnoreBalanceMismatch bool `json:"ignoreBalanceMismatch"` + // IgnoreUnclaimed skips adding unclaimed L1→L2 deposits to the certificate in Step E. + // The step still detects and warns about any unclaimed deposits, but the certificate is left unchanged. + IgnoreUnclaimed bool `json:"ignoreUnclaimed"` + // ExtraERC20Contracts is an optional list of ERC-20 contract addresses whose token holders + // are decomposed in Step B3. Each contract is queried with balanceOf for every EOA address + // collected in Step A. + ExtraERC20Contracts []common.Address `json:"extraErc20Contracts,omitempty"` + // BridgeServiceURL is the base URL of the bridge service REST API. + // When set, Step E queries the bridge service for pending bridges targeting this L2 and returns an + // error if any unclaimed deposits are found. + // Aggkit example: "http://127.0.0.1:32970" + // zkevm example: "http://127.0.0.1:33019" + BridgeServiceURL string `json:"bridgeServiceURL"` + // BridgeServiceType selects the bridge service API flavour: "aggkit" (default) or "zkevm". + BridgeServiceType string `json:"bridgeServiceType"` + // IgnoreUnsupportedL2Events, when true, makes the Step G lite syncer log a warning + // and continue instead of aborting when it sees an L2 event that would invalidate a + // BridgeEvent-only reconstruction (SetSovereignTokenAddress, MigrateLegacyToken, + // RemoveLegacySovereignTokenAddress, BackwardLET, ForwardLET). The computed NewLocalExitRoot may + // then be incorrect; enable only to inspect such a chain knowingly. Defaults to false. + IgnoreUnsupportedL2Events bool `json:"ignoreUnsupportedL2Events"` + // VerifyNewLocalExitRootUsingShadowFork, when true (the default), makes Step G2 spin up the Anvil + // shadow-fork, replay every bridge exit against the real bridge contract, and verify the computed + // NewLocalExitRoot against the contract's getRoot(). When false, Step G2 computes the + // NewLocalExitRoot purely off-chain from the lite exit tree (Step G1's genesis→fork bridges plus + // the certificate's bridge exits) without launching Anvil — much faster, but it trusts the + // off-chain leaf encoding (notably each exit's metadata) rather than verifying it on-chain. + VerifyNewLocalExitRootUsingShadowFork bool `json:"verifyNewLocalExitRootUsingShadowFork"` +} + +// Config holds all parameters required by the exit certificate tool. +type Config struct { + L2RPCURL string `json:"l2RpcUrl"` + L1RPCURL string `json:"l1RpcUrl"` + L2BridgeAddress common.Address `json:"l2BridgeAddress"` + L1BridgeAddress common.Address `json:"l1BridgeAddress"` + L2NetworkID uint32 `json:"l2NetworkId"` + TargetBlock aggkittypes.BlockNumberFinality `json:"targetBlock"` + ExitAddress common.Address `json:"exitAddress"` + DestinationNetwork uint32 `json:"destinationNetwork"` + SovereignRollupAddr common.Address `json:"sovereignRollupAddr"` + // L1GlobalExitRootAddress is the address of the PolygonZkEVMGlobalExitRootV2 contract on L1. + // Required for Step I to fetch the L1InfoTreeLeafCount from UpdateL1InfoTreeV2 events. + L1GlobalExitRootAddress common.Address `json:"l1GlobalExitRootAddress"` + // RollupManagerAddress is the optional address of the PolygonRollupManager (AgglayerManager) + // contract on L1. Used by Step WAIT to confirm the certificate was settled on L1 by scanning for + // the VerifyBatchesTrustedAggregator event matching the rollupID and the certificate's exit root. + // When unset it is resolved on-chain from SovereignRollupAddr.rollupManager() (PolygonConsensusBase). + RollupManagerAddress common.Address `json:"rollupManagerAddress"` + Options Options `json:"options"` + SignerConfig signertypes.SignerConfig `json:"-"` +} + +const ( + defaultBlockRange = 5000 + defaultStepAWindowSize = 150000 + defaultConcurrencyLimit = 20 + defaultRPCBatchSize = 200 +) + +var defaultOptions = Options{ + BlockRange: defaultBlockRange, + StepAWindowSize: defaultStepAWindowSize, + ConcurrencyLimit: defaultConcurrencyLimit, + RPCBatchSize: defaultRPCBatchSize, + RPCDelayMs: 0, + OutputDir: "output", + L1StartBlock: 0, + L2StartBlock: 0, + UseAgglayerAdminToStepFCheck: true, + VerifyNewLocalExitRootUsingShadowFork: true, + // IgnoreGenesisBalance defaults to false (do abort on a genesis preload). +} + +// LoadConfig reads and validates the config file. The format is selected by file extension: +// ".toml" is parsed as TOML, anything else (".json" or no extension) as JSON. +func LoadConfig(configPath string) (*Config, error) { + raw, err := readRawConfig(configPath) + if err != nil { + return nil, err + } + if err := validateRawConfig(raw); err != nil { + return nil, err + } + return buildConfig(raw, filepath.Dir(configPath)) +} + +// readRawConfig reads the config file at configPath, normalizing TOML to JSON so a single code path +// handles both formats (including the signerConfig json.RawMessage and agglayerClient custom JSON +// unmarshalling), then unmarshals it into a rawConfig. +func readRawConfig(configPath string) (*rawConfig, error) { + data, err := os.ReadFile(configPath) + if err != nil { + return nil, fmt.Errorf("read config file %s: %w", configPath, err) + } + + if strings.EqualFold(filepath.Ext(configPath), ".toml") { + data, err = tomlToJSON(data) + if err != nil { + return nil, fmt.Errorf("parse config TOML %s: %w", configPath, err) + } + } + + var raw rawConfig + if err := json.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("parse config JSON: %w", err) + } + return &raw, nil +} + +// validateRawConfig checks the required parameters and the exitAddress format/value. +func validateRawConfig(raw *rawConfig) error { + if raw.L2RPCURL == "" { + return fmt.Errorf("missing required parameter: l2RpcUrl") + } + if raw.L2BridgeAddress == "" { + return fmt.Errorf("missing required parameter: l2BridgeAddress") + } + if raw.ExitAddress == "" { + return fmt.Errorf("missing required parameter: exitAddress") + } + // Validate the hex format explicitly: common.HexToAddress silently returns the zero address on + // any malformed input, so without this check a typo would surface as the (misleading) zero-address + // error below instead of pointing at the real problem. + if !common.IsHexAddress(raw.ExitAddress) { + return fmt.Errorf("invalid exitAddress %q: not a valid hex address", raw.ExitAddress) + } + if common.HexToAddress(raw.ExitAddress) == (common.Address{}) { + return fmt.Errorf("invalid exitAddress: the zero address (0x00...00) is not allowed; " + + "set an address whose private key you control so the SC-locked funds can be recovered") + } + // Step F (the agglayer admin balance check) needs agglayerAdminURL. When the check is enabled + // (useAgglayerAdminToStepFCheck, default true), the URL must be set; otherwise set the flag to + // false to skip Step F entirely. + if useAgglayerAdminToStepFCheckEnabled(raw.Options) && + (raw.Options == nil || raw.Options.AgglayerAdminURL == "") { + return fmt.Errorf("options.agglayerAdminURL is required when options.useAgglayerAdminToStepFCheck " + + "is true (the default); set agglayerAdminURL, or set useAgglayerAdminToStepFCheck=false to skip Step F") + } + return nil +} + +// useAgglayerAdminToStepFCheckEnabled reports the effective value of +// options.useAgglayerAdminToStepFCheck, mirroring the default applied by mergeOptions: it is true +// when the option is absent (nil rawOpts or unset tri-state flag) and otherwise takes the explicit value. +func useAgglayerAdminToStepFCheckEnabled(raw *rawOpts) bool { + if raw == nil || raw.UseAgglayerAdminToStepFCheck == nil { + return defaultOptions.UseAgglayerAdminToStepFCheck + } + return *raw.UseAgglayerAdminToStepFCheck +} + +// buildConfig assembles a *Config from an already-validated rawConfig, applying defaults +// (l1BridgeAddress, l2NetworkId) and parsing the targetBlock, options and signerConfig. +func buildConfig(raw *rawConfig, configDir string) (*Config, error) { + targetBlock, err := parseTargetBlock(raw.TargetBlock) + if err != nil { + return nil, fmt.Errorf("invalid targetBlock %q: %w", raw.TargetBlock, err) + } + + cfg := &Config{ + L2RPCURL: raw.L2RPCURL, + L1RPCURL: raw.L1RPCURL, + L2BridgeAddress: common.HexToAddress(raw.L2BridgeAddress), + L2NetworkID: raw.L2NetworkID, + ExitAddress: common.HexToAddress(raw.ExitAddress), + DestinationNetwork: raw.DestinationNetwork, + TargetBlock: targetBlock, + SovereignRollupAddr: common.HexToAddress(raw.SovereignRollupAddr), + L1GlobalExitRootAddress: common.HexToAddress(raw.L1GlobalExitRootAddress), + RollupManagerAddress: common.HexToAddress(raw.RollupManagerAddress), + } + + if raw.L1BridgeAddress != "" { + cfg.L1BridgeAddress = common.HexToAddress(raw.L1BridgeAddress) + } else { + cfg.L1BridgeAddress = cfg.L2BridgeAddress + } + + if cfg.L2NetworkID == 0 { + cfg.L2NetworkID = 1 + } + + cfg.Options = mergeOptions(raw.Options, configDir) + if len(raw.SignerConfig) > 0 { + signerCfg, err := parseSignerConfig(raw.SignerConfig, configDir) + if err != nil { + return nil, fmt.Errorf("parse signerConfig: %w", err) + } + cfg.SignerConfig = signerCfg + } + + return cfg, nil +} + +// tomlToJSON decodes TOML into a generic map and re-encodes it as JSON, so the existing JSON +// unmarshalling (rawConfig) can handle both formats from one code path. +func tomlToJSON(data []byte) ([]byte, error) { + var raw map[string]any + if err := toml.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("unmarshal TOML: %w", err) + } + out, err := json.Marshal(raw) + if err != nil { + return nil, fmt.Errorf("re-encode config as JSON: %w", err) + } + return out, nil +} + +// parseTargetBlock converts the raw JSON string to a BlockNumberFinality. +// An empty value resolves to LatestBlock; any other invalid value returns an error. +func parseTargetBlock(s string) (aggkittypes.BlockNumberFinality, error) { + if s == "" { + return aggkittypes.LatestBlock, nil + } + tb, err := aggkittypes.NewBlockNumberFinality(s) + if err != nil { + return aggkittypes.LatestBlock, err + } + return *tb, nil +} + +// parseSignerConfig converts the flat JSON signer config into a SignerConfig. +// The JSON format mirrors the TOML used by aggsender: +// +// { "Method": "local", "Path": "keystore.json", "Password": "pass" } +func parseSignerConfig(data json.RawMessage, configDir string) (signertypes.SignerConfig, error) { + var raw map[string]any + if err := json.Unmarshal(data, &raw); err != nil { + return signertypes.SignerConfig{}, fmt.Errorf("unmarshal signer config: %w", err) + } + method, _ := raw["Method"].(string) + + // The go_signer library looks up config keys in lowercase (e.g. "path", "password"). + // Normalize all non-Method keys to lowercase so JSON with "Path"/"Password" works. + cfg := make(map[string]any, len(raw)) + for k, v := range raw { + if k == "Method" { + continue + } + key := strings.ToLower(k) + if key == "path" { + if s, ok := v.(string); ok { + v = resolvePath(configDir, s) + } + } + cfg[key] = v + } + return signertypes.SignerConfig{ + Method: signertypes.SignMethod(method), + Config: cfg, + }, nil +} + +func resolvePath(baseDir, path string) string { + if path == "" { + return "" + } + if filepath.IsAbs(path) { + return path + } + return filepath.Join(baseDir, path) +} + +func mergeOptions(raw *rawOpts, configDir string) Options { + opts := defaultOptions + if raw == nil { + return opts + } + mergeScalarOptions(&opts, raw, configDir) + mergeFlagOptions(&opts, raw) + if raw.AgglayerClient != nil { + opts.AgglayerClient = mergeAgglayerClient(raw.AgglayerClient) + } + return opts +} + +// mergeScalarOptions overrides the non-boolean option fields with any non-zero raw values. +func mergeScalarOptions(opts *Options, raw *rawOpts, configDir string) { + if raw.BlockRange > 0 { + opts.BlockRange = raw.BlockRange + } + if raw.StepAWindowSize > 0 { + opts.StepAWindowSize = raw.StepAWindowSize + } + if raw.ConcurrencyLimit > 0 { + opts.ConcurrencyLimit = raw.ConcurrencyLimit + } + if raw.RPCBatchSize > 0 { + opts.RPCBatchSize = raw.RPCBatchSize + } + if raw.RPCDelayMs > 0 { + opts.RPCDelayMs = raw.RPCDelayMs + } + if raw.OutputDir != "" { + opts.OutputDir = resolvePath(configDir, raw.OutputDir) + } + if raw.L1StartBlock > 0 { + opts.L1StartBlock = raw.L1StartBlock + } + if raw.L2StartBlock > 0 { + opts.L2StartBlock = raw.L2StartBlock + } + if raw.AgglayerAdminURL != "" { + opts.AgglayerAdminURL = raw.AgglayerAdminURL + } + if raw.AgglayerAdminToken != "" { + opts.AgglayerAdminToken = raw.AgglayerAdminToken + } + if len(raw.ExtraERC20Contracts) > 0 { + addrs := make([]common.Address, 0, len(raw.ExtraERC20Contracts)) + for _, s := range raw.ExtraERC20Contracts { + addrs = append(addrs, common.HexToAddress(s)) + } + opts.ExtraERC20Contracts = addrs + } + if raw.BridgeServiceURL != "" { + opts.BridgeServiceURL = raw.BridgeServiceURL + } + if raw.BridgeServiceType != "" { + opts.BridgeServiceType = raw.BridgeServiceType + } +} + +// mergeFlagOptions overrides the boolean (tri-state *bool) option flags that were explicitly set. +func mergeFlagOptions(opts *Options, raw *rawOpts) { + if raw.UseAgglayerAdminToStepFCheck != nil { + opts.UseAgglayerAdminToStepFCheck = *raw.UseAgglayerAdminToStepFCheck + } + if raw.IgnoreGenesisBalance != nil { + opts.IgnoreGenesisBalance = *raw.IgnoreGenesisBalance + } + if raw.IgnoreOnTraceError != nil { + opts.IgnoreOnTraceError = *raw.IgnoreOnTraceError + } + if raw.IgnoreBalanceMismatch != nil { + opts.IgnoreBalanceMismatch = *raw.IgnoreBalanceMismatch + } + if raw.IgnoreUnclaimed != nil { + opts.IgnoreUnclaimed = *raw.IgnoreUnclaimed + } + if raw.IgnoreUnsupportedL2Events != nil { + opts.IgnoreUnsupportedL2Events = *raw.IgnoreUnsupportedL2Events + } + if raw.VerifyNewLocalExitRootUsingShadowFork != nil { + opts.VerifyNewLocalExitRootUsingShadowFork = *raw.VerifyNewLocalExitRootUsingShadowFork + } +} + +// mergeAgglayerClient overlays the raw agglayer client config onto the gRPC defaults, keeping each +// default when its corresponding raw field is unset. +func mergeAgglayerClient(raw *agglayer.ClientConfig) agglayer.ClientConfig { + clientCfg := *raw + grpcDefaults := aggkitgrpc.DefaultConfig() + if g := clientCfg.GRPC; g != nil { + if g.URL != "" { + grpcDefaults.URL = g.URL + } + if g.MinConnectTimeout.Duration != 0 { + grpcDefaults.MinConnectTimeout = g.MinConnectTimeout + } + if g.RequestTimeout.Duration != 0 { + grpcDefaults.RequestTimeout = g.RequestTimeout + } + if g.UseTLS { + grpcDefaults.UseTLS = g.UseTLS + } + if g.Retry != nil { + grpcDefaults.Retry = g.Retry + } + } + clientCfg.GRPC = grpcDefaults + return clientCfg +} + +// rawConfig mirrors the JSON structure with string addresses. +type rawConfig struct { + L2RPCURL string `json:"l2RpcUrl"` + L1RPCURL string `json:"l1RpcUrl"` + L2BridgeAddress string `json:"l2BridgeAddress"` + L1BridgeAddress string `json:"l1BridgeAddress"` + L2NetworkID uint32 `json:"l2NetworkId"` + TargetBlock string `json:"targetBlock"` + ExitAddress string `json:"exitAddress"` + DestinationNetwork uint32 `json:"destinationNetwork"` + SovereignRollupAddr string `json:"sovereignRollupAddr"` + L1GlobalExitRootAddress string `json:"l1GlobalExitRootAddress"` + RollupManagerAddress string `json:"rollupManagerAddress"` + Options *rawOpts `json:"options"` + SignerConfig json.RawMessage `json:"signerConfig"` +} + +type rawOpts struct { + BlockRange int `json:"blockRange"` + StepAWindowSize int `json:"stepAWindowSize"` + ConcurrencyLimit int `json:"concurrencyLimit"` + RPCBatchSize int `json:"rpcBatchSize"` + RPCDelayMs int `json:"rpcDelayMs"` + OutputDir string `json:"outputDir"` + L1StartBlock uint64 `json:"l1StartBlock"` + L2StartBlock uint64 `json:"l2StartBlock"` + AgglayerAdminURL string `json:"agglayerAdminURL"` + AgglayerAdminToken string `json:"agglayerAdminToken"` + AgglayerClient *agglayer.ClientConfig `json:"agglayerClient"` + UseAgglayerAdminToStepFCheck *bool `json:"useAgglayerAdminToStepFCheck"` + IgnoreGenesisBalance *bool `json:"ignoreGenesisBalance"` + IgnoreOnTraceError *bool `json:"ignoreOnTraceError"` + IgnoreBalanceMismatch *bool `json:"ignoreBalanceMismatch"` + IgnoreUnclaimed *bool `json:"ignoreUnclaimed"` + ExtraERC20Contracts []string `json:"extraErc20Contracts"` + BridgeServiceURL string `json:"bridgeServiceURL"` + BridgeServiceType string `json:"bridgeServiceType"` + IgnoreUnsupportedL2Events *bool `json:"ignoreUnsupportedL2Events"` + VerifyNewLocalExitRootUsingShadowFork *bool `json:"verifyNewLocalExitRootUsingShadowFork"` +} + +// --- LBT file parsing --- + +// rawLBTEntry handles both string-encoded ("0") and numeric (0) originNetwork via json.Number. +type rawLBTEntry struct { + WrappedTokenAddress string `json:"wrappedTokenAddress"` + OriginNetwork json.Number `json:"originNetwork"` + OriginTokenAddress string `json:"originTokenAddress"` + Balance string `json:"balance"` +} + +func (r rawLBTEntry) toLBTEntry() LBTEntry { + return LBTEntry{ + WrappedTokenAddress: common.HexToAddress(r.WrappedTokenAddress), + OriginNetwork: parseJSONNumber(r.OriginNetwork), + OriginTokenAddress: common.HexToAddress(r.OriginTokenAddress), + Balance: r.Balance, + } +} + +func parseJSONNumber(n json.Number) uint32 { + v, err := n.Int64() + if err != nil { + return 0 + } + return uint32(v) +} + +// LoadLBTWrappedTokens reads the LBT JSON file and returns only non-zero-address tokens. +func LoadLBTWrappedTokens(lbtFilePath string) ([]WrappedToken, error) { + if lbtFilePath == "" { + return nil, nil + } + entries, err := LoadLBTEntries(lbtFilePath) + if err != nil { + return nil, err + } + return LBTEntriesToWrappedTokens(entries), nil +} + +// LoadLBTEntries reads the full LBT JSON file. +func LoadLBTEntries(lbtFilePath string) ([]LBTEntry, error) { + if lbtFilePath == "" { + return nil, nil + } + + f, err := os.Open(lbtFilePath) + if err != nil { + return nil, fmt.Errorf("read LBT file %s: %w", lbtFilePath, err) + } + defer f.Close() + + dec := json.NewDecoder(f) + dec.UseNumber() + + var raw []rawLBTEntry + if err := dec.Decode(&raw); err != nil { + return nil, fmt.Errorf("parse LBT JSON: %w", err) + } + + entries := make([]LBTEntry, len(raw)) + for i, r := range raw { + entries[i] = r.toLBTEntry() + } + return entries, nil +} + +// LBTEntriesToWrappedTokens extracts the wrapped token list from LBT entries, +// filtering out entries with a zero wrappedTokenAddress (native token entry). +func LBTEntriesToWrappedTokens(entries []LBTEntry) []WrappedToken { + var tokens []WrappedToken + for _, e := range entries { + if e.WrappedTokenAddress != (common.Address{}) { + tokens = append(tokens, WrappedToken{ + WrappedTokenAddress: e.WrappedTokenAddress, + OriginNetwork: e.OriginNetwork, + OriginTokenAddress: e.OriginTokenAddress, + }) + } + } + return tokens +} diff --git a/tools/exit_certificate/config_test.go b/tools/exit_certificate/config_test.go new file mode 100644 index 000000000..b04ebada8 --- /dev/null +++ b/tools/exit_certificate/config_test.go @@ -0,0 +1,682 @@ +package exit_certificate + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "testing" + + aggkittypes "github.com/agglayer/aggkit/types" + signertypes "github.com/agglayer/go_signer/signer/types" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +func TestLoadConfig_FileNotFound(t *testing.T) { + t.Parallel() + _, err := LoadConfig("/nonexistent/path/parameters.json") + require.Error(t, err) + require.Contains(t, err.Error(), "read config file") +} + +func TestLoadConfig_InvalidJSON(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "bad.json") + require.NoError(t, os.WriteFile(path, []byte("{not valid json}"), 0o600)) + + _, err := LoadConfig(path) + require.Error(t, err) + require.Contains(t, err.Error(), "parse config JSON") +} + +func TestLoadConfig_MissingL2RPCURL(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "missing.json") + data := `{"l2BridgeAddress": "0x2a3DD3EB832aF982ec71669E178424b10Dca2EDe", "targetBlock": "100"}` + require.NoError(t, os.WriteFile(path, []byte(data), 0o600)) + + _, err := LoadConfig(path) + require.Error(t, err) + require.Contains(t, err.Error(), "l2RpcUrl") +} + +func TestLoadConfig_MissingL2BridgeAddress(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "missing.json") + data := `{"l2RpcUrl": "http://localhost:8545", "targetBlock": "100"}` + require.NoError(t, os.WriteFile(path, []byte(data), 0o600)) + + _, err := LoadConfig(path) + require.Error(t, err) + require.Contains(t, err.Error(), "l2BridgeAddress") +} + +func TestLoadConfig_MissingExitAddress(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "missing.json") + data := `{ + "l2RpcUrl": "http://localhost:8545", + "l2BridgeAddress": "0x2a3DD3EB832aF982ec71669E178424b10Dca2EDe", + "targetBlock": "100" + }` + require.NoError(t, os.WriteFile(path, []byte(data), 0o600)) + + _, err := LoadConfig(path) + require.Error(t, err) + require.Contains(t, err.Error(), "exitAddress") +} + +func TestLoadConfig_ZeroExitAddress(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "zero.json") + data := `{ + "l2RpcUrl": "http://localhost:8545", + "l2BridgeAddress": "0x2a3DD3EB832aF982ec71669E178424b10Dca2EDe", + "exitAddress": "0x0000000000000000000000000000000000000000", + "targetBlock": "100" + }` + require.NoError(t, os.WriteFile(path, []byte(data), 0o600)) + + _, err := LoadConfig(path) + require.Error(t, err) + require.Contains(t, err.Error(), "exitAddress") + require.Contains(t, err.Error(), "zero address") +} + +func TestLoadConfig_InvalidExitAddress(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "invalid.json") + data := `{ + "l2RpcUrl": "http://localhost:8545", + "l2BridgeAddress": "0x2a3DD3EB832aF982ec71669E178424b10Dca2EDe", + "exitAddress": "not-an-address", + "targetBlock": "100" + }` + require.NoError(t, os.WriteFile(path, []byte(data), 0o600)) + + _, err := LoadConfig(path) + require.Error(t, err) + require.Contains(t, err.Error(), "exitAddress") + require.Contains(t, err.Error(), "not a valid hex address") +} + +func TestLoadConfig_MinimalValid(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "minimal.json") + data := `{ + "l2RpcUrl": "http://localhost:8545", + "l2BridgeAddress": "0x2a3DD3EB832aF982ec71669E178424b10Dca2EDe", + "exitAddress": "0x0000000000000000000000000000000000000001", + "targetBlock": "100", + "options": { + "agglayerAdminURL": "https://admin.example.com" + } + }` + require.NoError(t, os.WriteFile(path, []byte(data), 0o600)) + + cfg, err := LoadConfig(path) + require.NoError(t, err) + require.Equal(t, "http://localhost:8545", cfg.L2RPCURL) + require.Equal(t, common.HexToAddress("0x2a3DD3EB832aF982ec71669E178424b10Dca2EDe"), cfg.L2BridgeAddress) + require.Equal(t, common.HexToAddress("0x0000000000000000000000000000000000000001"), cfg.ExitAddress) + require.Equal(t, *aggkittypes.NewBlockNumber(100), cfg.TargetBlock) + require.Equal(t, uint32(1), cfg.L2NetworkID) + require.Equal(t, cfg.L2BridgeAddress, cfg.L1BridgeAddress) +} + +func TestLoadConfig_FullConfig(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "full.json") + data := `{ + "l2RpcUrl": "http://l2:8545", + "l1RpcUrl": "http://l1:8545", + "l2BridgeAddress": "0x2a3DD3EB832aF982ec71669E178424b10Dca2EDe", + "l1BridgeAddress": "0x1111111111111111111111111111111111111111", + "l2NetworkId": 5, + "targetBlock": "LatestBlock", + "exitAddress": "0x0000000000000000000000000000000000000001", + "destinationNetwork": 0, + "options": { + "agglayerAdminURL": "https://admin.example.com", + "blockRange": 10000, + "concurrencyLimit": 200, + "rpcBatchSize": 200, + "rpcDelayMs": 10, + "l1StartBlock": 1000 + } + }` + require.NoError(t, os.WriteFile(path, []byte(data), 0o600)) + + cfg, err := LoadConfig(path) + require.NoError(t, err) + require.Equal(t, "http://l2:8545", cfg.L2RPCURL) + require.Equal(t, "http://l1:8545", cfg.L1RPCURL) + require.Equal(t, uint32(5), cfg.L2NetworkID) + require.Equal(t, aggkittypes.LatestBlock, cfg.TargetBlock) + require.Equal(t, common.HexToAddress("0x0000000000000000000000000000000000000001"), cfg.ExitAddress) + require.Equal(t, common.HexToAddress("0x1111111111111111111111111111111111111111"), cfg.L1BridgeAddress) + require.Equal(t, 10000, cfg.Options.BlockRange) + require.Equal(t, 200, cfg.Options.ConcurrencyLimit) + require.Equal(t, 200, cfg.Options.RPCBatchSize) + require.Equal(t, 10, cfg.Options.RPCDelayMs) + require.Equal(t, uint64(1000), cfg.Options.L1StartBlock) +} + +func TestLoadConfig_FullConfigTOML(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "full.toml") + data := ` +l2RpcUrl = "http://l2:8545" +l1RpcUrl = "http://l1:8545" +l2BridgeAddress = "0x2a3DD3EB832aF982ec71669E178424b10Dca2EDe" +l1BridgeAddress = "0x1111111111111111111111111111111111111111" +l2NetworkId = 5 +targetBlock = "LatestBlock" +exitAddress = "0x0000000000000000000000000000000000000001" +destinationNetwork = 0 + +[options] +agglayerAdminURL = "https://admin.example.com" +blockRange = 10000 +concurrencyLimit = 200 +rpcBatchSize = 200 +rpcDelayMs = 10 +l1StartBlock = 1000 + +[signerConfig] +Method = "local" +Path = "keystore.json" +Password = "pass" +` + require.NoError(t, os.WriteFile(path, []byte(data), 0o600)) + + cfg, err := LoadConfig(path) + require.NoError(t, err) + require.Equal(t, "http://l2:8545", cfg.L2RPCURL) + require.Equal(t, "http://l1:8545", cfg.L1RPCURL) + require.Equal(t, uint32(5), cfg.L2NetworkID) + require.Equal(t, aggkittypes.LatestBlock, cfg.TargetBlock) + require.Equal(t, common.HexToAddress("0x0000000000000000000000000000000000000001"), cfg.ExitAddress) + require.Equal(t, common.HexToAddress("0x1111111111111111111111111111111111111111"), cfg.L1BridgeAddress) + require.Equal(t, 10000, cfg.Options.BlockRange) + require.Equal(t, 200, cfg.Options.ConcurrencyLimit) + require.Equal(t, 200, cfg.Options.RPCBatchSize) + require.Equal(t, 10, cfg.Options.RPCDelayMs) + require.Equal(t, uint64(1000), cfg.Options.L1StartBlock) + // signerConfig round-trips through TOML: Method is preserved and Path is resolved relative + // to the config dir, mirroring the JSON behaviour. + require.Equal(t, signertypes.SignMethod("local"), cfg.SignerConfig.Method) + require.Equal(t, filepath.Join(filepath.Dir(path), "keystore.json"), cfg.SignerConfig.Config["path"]) + require.Equal(t, "pass", cfg.SignerConfig.Config["password"]) +} + +func TestLoadConfig_InvalidTOML(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "bad.toml") + require.NoError(t, os.WriteFile(path, []byte("this is = not = valid = toml"), 0o600)) + + _, err := LoadConfig(path) + require.Error(t, err) + require.Contains(t, err.Error(), "TOML") +} + +func TestLoadConfig_DefaultOptions(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "defaults.json") + data := `{ + "l2RpcUrl": "http://localhost:8545", + "l2BridgeAddress": "0x2a3DD3EB832aF982ec71669E178424b10Dca2EDe", + "exitAddress": "0x0000000000000000000000000000000000000001", + "targetBlock": "100", + "options": { + "agglayerAdminURL": "https://admin.example.com" + } + }` + require.NoError(t, os.WriteFile(path, []byte(data), 0o600)) + + cfg, err := LoadConfig(path) + require.NoError(t, err) + require.Equal(t, 5000, cfg.Options.BlockRange) + require.Equal(t, 150000, cfg.Options.StepAWindowSize) + require.Equal(t, 20, cfg.Options.ConcurrencyLimit) + require.Equal(t, 200, cfg.Options.RPCBatchSize) + require.Equal(t, 0, cfg.Options.RPCDelayMs) + require.Equal(t, uint64(0), cfg.Options.L1StartBlock) + require.True(t, cfg.Options.UseAgglayerAdminToStepFCheck) +} + +func TestLoadConfig_StepAWindowSize(t *testing.T) { + t.Parallel() + + t.Run("explicit value is read from file", func(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "cfg.json") + data := `{ + "l2RpcUrl": "http://localhost:8545", + "l2BridgeAddress": "0x2a3DD3EB832aF982ec71669E178424b10Dca2EDe", + "exitAddress": "0x0000000000000000000000000000000000000001", + "targetBlock": "100", + "options": { + "agglayerAdminURL": "https://admin.example.com", + "stepAWindowSize": 2000 + } + }` + require.NoError(t, os.WriteFile(path, []byte(data), 0o600)) + + cfg, err := LoadConfig(path) + require.NoError(t, err) + require.Equal(t, 2000, cfg.Options.StepAWindowSize) + }) + + t.Run("defaults to 5000 when absent", func(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "cfg.json") + data := `{ + "l2RpcUrl": "http://localhost:8545", + "l2BridgeAddress": "0x2a3DD3EB832aF982ec71669E178424b10Dca2EDe", + "exitAddress": "0x0000000000000000000000000000000000000001", + "targetBlock": "100", + "options": { + "agglayerAdminURL": "https://admin.example.com" + } + }` + require.NoError(t, os.WriteFile(path, []byte(data), 0o600)) + + cfg, err := LoadConfig(path) + require.NoError(t, err) + require.Equal(t, defaultStepAWindowSize, cfg.Options.StepAWindowSize) + }) +} + +func TestLoadConfig_RelativeOutputDir(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + path := filepath.Join(dir, "parameters.json") + data := `{ + "l2RpcUrl": "http://localhost:8545", + "l2BridgeAddress": "0x2a3DD3EB832aF982ec71669E178424b10Dca2EDe", + "exitAddress": "0x0000000000000000000000000000000000000001", + "targetBlock": "100", + "options": { + "agglayerAdminURL": "https://admin.example.com", + "outputDir": "./output" + } + }` + require.NoError(t, os.WriteFile(path, []byte(data), 0o600)) + + cfg, err := LoadConfig(path) + require.NoError(t, err) + require.Equal(t, filepath.Join(dir, "output"), cfg.Options.OutputDir) +} + +func TestLoadLBTWrappedTokens_EmptyPath(t *testing.T) { + t.Parallel() + tokens, err := LoadLBTWrappedTokens("") + require.NoError(t, err) + require.Nil(t, tokens) +} + +func TestLoadLBTWrappedTokens_FileNotFound(t *testing.T) { + t.Parallel() + _, err := LoadLBTWrappedTokens("/nonexistent/file.json") + require.Error(t, err) +} + +func TestLoadLBTWrappedTokens_ValidFile(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + path := filepath.Join(dir, "lbt.json") + + entries := []LBTEntry{ + { + WrappedTokenAddress: common.HexToAddress("0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"), + OriginNetwork: 0, + OriginTokenAddress: common.HexToAddress("0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"), + Balance: "1000000", + }, + { + WrappedTokenAddress: common.Address{}, + OriginNetwork: 0, + OriginTokenAddress: common.Address{}, + Balance: "500000", + }, + { + WrappedTokenAddress: common.HexToAddress("0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC"), + OriginNetwork: 1, + OriginTokenAddress: common.HexToAddress("0xDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD"), + Balance: "2000000", + }, + } + + data, err := json.Marshal(entries) + require.NoError(t, err) + require.NoError(t, os.WriteFile(path, data, 0o600)) + + tokens, err := LoadLBTWrappedTokens(path) + require.NoError(t, err) + require.Len(t, tokens, 2) + require.Equal(t, common.HexToAddress("0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"), tokens[0].WrappedTokenAddress) + require.Equal(t, common.HexToAddress("0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC"), tokens[1].WrappedTokenAddress) +} + +func TestLoadConfig_AgglayerAdminToken(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "cfg.json") + data := `{ + "l2RpcUrl": "http://localhost:8545", + "l2BridgeAddress": "0x2a3DD3EB832aF982ec71669E178424b10Dca2EDe", + "exitAddress": "0x0000000000000000000000000000000000000001", + "targetBlock": "100", + "options": { + "agglayerAdminURL": "https://admin.example.com", + "agglayerAdminToken": "test-jwt-token" + } + }` + require.NoError(t, os.WriteFile(path, []byte(data), 0o600)) + + cfg, err := LoadConfig(path) + require.NoError(t, err) + require.Equal(t, "https://admin.example.com", cfg.Options.AgglayerAdminURL) + require.Equal(t, "test-jwt-token", cfg.Options.AgglayerAdminToken) +} + +func TestParseSignerConfig_Valid(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + raw := json.RawMessage(`{"Method": "local", "Path": "keystore.json", "Password": "secret"}`) + + cfg, err := parseSignerConfig(raw, dir) + require.NoError(t, err) + require.Equal(t, "local", string(cfg.Method)) + require.Equal(t, filepath.Join(dir, "keystore.json"), cfg.Config["path"]) + require.Equal(t, "secret", cfg.Config["password"]) +} + +func TestParseSignerConfig_InvalidJSON(t *testing.T) { + t.Parallel() + _, err := parseSignerConfig(json.RawMessage(`{bad}`), "/tmp") + require.Error(t, err) +} + +func TestMergeOptions_BoolFlags(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "cfg.json") + data := `{ + "l2RpcUrl": "http://localhost:8545", + "l2BridgeAddress": "0x2a3DD3EB832aF982ec71669E178424b10Dca2EDe", + "exitAddress": "0x0000000000000000000000000000000000000001", + "targetBlock": "100", + "options": { + "agglayerAdminURL": "https://admin.example.com", + "ignoreGenesisBalance": true, + "ignoreOnTraceError": true, + "ignoreBalanceMismatch": true, + "ignoreUnclaimed": true + } + }` + require.NoError(t, os.WriteFile(path, []byte(data), 0o600)) + + cfg, err := LoadConfig(path) + require.NoError(t, err) + require.True(t, cfg.Options.IgnoreGenesisBalance) + require.True(t, cfg.Options.IgnoreOnTraceError) + require.True(t, cfg.Options.IgnoreBalanceMismatch) + require.True(t, cfg.Options.IgnoreUnclaimed) +} + +func TestMergeOptions_UseAgglayerAdminToStepFCheck(t *testing.T) { + t.Parallel() + + const base = `{ + "l2RpcUrl": "http://localhost:8545", + "l2BridgeAddress": "0x2a3DD3EB832aF982ec71669E178424b10Dca2EDe", + "exitAddress": "0x0000000000000000000000000000000000000001", + "targetBlock": "100"%s + }` + + t.Run("defaults to true when absent (with agglayerAdminURL)", func(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "cfg.json") + opts := `, + "options": { "agglayerAdminURL": "https://admin.example.com" }` + require.NoError(t, os.WriteFile(path, fmt.Appendf(nil, base, opts), 0o600)) + + cfg, err := LoadConfig(path) + require.NoError(t, err) + require.True(t, cfg.Options.UseAgglayerAdminToStepFCheck) + }) + + t.Run("explicit false is honored and does not require agglayerAdminURL", func(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "cfg.json") + opts := `, + "options": { "useAgglayerAdminToStepFCheck": false }` + require.NoError(t, os.WriteFile(path, fmt.Appendf(nil, base, opts), 0o600)) + + cfg, err := LoadConfig(path) + require.NoError(t, err) + require.False(t, cfg.Options.UseAgglayerAdminToStepFCheck) + }) + + t.Run("explicit true is honored", func(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "cfg.json") + opts := `, + "options": { "useAgglayerAdminToStepFCheck": true, "agglayerAdminURL": "https://admin.example.com" }` + require.NoError(t, os.WriteFile(path, fmt.Appendf(nil, base, opts), 0o600)) + + cfg, err := LoadConfig(path) + require.NoError(t, err) + require.True(t, cfg.Options.UseAgglayerAdminToStepFCheck) + }) + + t.Run("errors when enabled by default without agglayerAdminURL", func(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "cfg.json") + require.NoError(t, os.WriteFile(path, fmt.Appendf(nil, base, ""), 0o600)) + + _, err := LoadConfig(path) + require.Error(t, err) + require.Contains(t, err.Error(), "agglayerAdminURL") + require.Contains(t, err.Error(), "useAgglayerAdminToStepFCheck") + }) + + t.Run("errors when explicitly enabled without agglayerAdminURL", func(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "cfg.json") + opts := `, + "options": { "useAgglayerAdminToStepFCheck": true }` + require.NoError(t, os.WriteFile(path, fmt.Appendf(nil, base, opts), 0o600)) + + _, err := LoadConfig(path) + require.Error(t, err) + require.Contains(t, err.Error(), "agglayerAdminURL") + }) +} + +func TestLoadConfig_AgglayerClient(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "cfg.json") + data := `{ + "l2RpcUrl": "http://localhost:8545", + "l2BridgeAddress": "0x2a3DD3EB832aF982ec71669E178424b10Dca2EDe", + "exitAddress": "0x0000000000000000000000000000000000000001", + "targetBlock": "100", + "options": { + "agglayerAdminURL": "https://admin.example.com", + "agglayerClient": { + "GRPC": { + "URL": "agglayer.example.com:50051", + "UseTLS": true + } + } + } + }` + require.NoError(t, os.WriteFile(path, []byte(data), 0o600)) + + cfg, err := LoadConfig(path) + require.NoError(t, err) + require.NotNil(t, cfg.Options.AgglayerClient.GRPC) + require.Equal(t, "agglayer.example.com:50051", cfg.Options.AgglayerClient.GRPC.URL) + require.True(t, cfg.Options.AgglayerClient.GRPC.UseTLS) +} + +func TestMergeOptions_BridgeService(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "cfg.json") + data := `{ + "l2RpcUrl": "http://localhost:8545", + "l2BridgeAddress": "0x2a3DD3EB832aF982ec71669E178424b10Dca2EDe", + "exitAddress": "0x0000000000000000000000000000000000000001", + "targetBlock": "100", + "options": { + "agglayerAdminURL": "https://admin.example.com", + "bridgeServiceURL": "http://bridge:8080", + "bridgeServiceType": "zkevm" + } + }` + require.NoError(t, os.WriteFile(path, []byte(data), 0o600)) + + cfg, err := LoadConfig(path) + require.NoError(t, err) + require.Equal(t, "http://bridge:8080", cfg.Options.BridgeServiceURL) + require.Equal(t, "zkevm", cfg.Options.BridgeServiceType) +} + +func TestParseTargetBlock(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + wantErr bool + wantBlock string + wantSpecific uint64 + }{ + { + name: "empty defaults to latest", + input: "", + wantBlock: "LatestBlock", + }, + { + name: "LatestBlock tag", + input: "LatestBlock", + wantBlock: "LatestBlock", + }, + { + name: "FinalizedBlock tag", + input: "FinalizedBlock", + wantBlock: "FinalizedBlock", + }, + { + name: "numeric block", + input: "12345", + wantSpecific: 12345, + }, + { + name: "typo FinalizedBock returns error", + input: "FinalizedBock", + wantErr: true, + }, + { + name: "hex garbage returns error", + input: "0xZZ", + wantErr: true, + }, + { + name: "random string returns error", + input: "notablock", + wantErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + result, err := parseTargetBlock(tc.input) + if tc.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + if tc.wantBlock != "" { + require.Equal(t, tc.wantBlock, result.Block.String()) + } + if tc.wantSpecific != 0 { + require.Equal(t, tc.wantSpecific, result.Specific) + } + }) + } +} + +func TestLoadConfig_InvalidTargetBlock(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "cfg.json") + data := `{ + "l2RpcUrl": "http://localhost:8545", + "l2BridgeAddress": "0x2a3DD3EB832aF982ec71669E178424b10Dca2EDe", + "exitAddress": "0x0000000000000000000000000000000000000001", + "targetBlock": "FinalizedBock", + "options": { + "agglayerAdminURL": "https://admin.example.com" + } + }` + require.NoError(t, os.WriteFile(path, []byte(data), 0o600)) + + _, err := LoadConfig(path) + require.Error(t, err) + require.Contains(t, err.Error(), "invalid targetBlock") + require.Contains(t, err.Error(), "FinalizedBock") +} + +func TestLoadLBTEntries_ValidFile(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + path := filepath.Join(dir, "lbt.json") + + entries := []LBTEntry{ + { + WrappedTokenAddress: common.HexToAddress("0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"), + OriginNetwork: 0, + OriginTokenAddress: common.HexToAddress("0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"), + Balance: "1000000", + }, + } + + data, err := json.Marshal(entries) + require.NoError(t, err) + require.NoError(t, os.WriteFile(path, data, 0o600)) + + result, err := LoadLBTEntries(path) + require.NoError(t, err) + require.Len(t, result, 1) + require.Equal(t, "1000000", result[0].Balance) +} diff --git a/tools/exit_certificate/extra_coverage_test.go b/tools/exit_certificate/extra_coverage_test.go new file mode 100644 index 000000000..803d3b067 --- /dev/null +++ b/tools/exit_certificate/extra_coverage_test.go @@ -0,0 +1,287 @@ +package exit_certificate + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +func TestRunStepAEmptyBlocks(t *testing.T) { + t.Parallel() + url := newBatchRPCServer(t, func(method string, _ []json.RawMessage) any { + if method == rpcMethodEthGetBlockByNumber { + return map[string]any{"transactions": []string{}} + } + return "0x" + }) + cfg := &Config{ + L2RPCURL: url, + Options: Options{RPCBatchSize: 10, ConcurrencyLimit: 2, StepAWindowSize: 100}, + } + res, err := RunStepA(context.Background(), cfg, 2) + require.NoError(t, err) + require.Empty(t, res.Addresses) +} + +func TestFetchWETHBalance(t *testing.T) { + t.Parallel() + weth := common.HexToAddress("0x000000000000000000000000000000000000abcd") + srv := newRPCStub(t, func(method string, params []any) (json.RawMessage, *jsonRPCError) { + require.Equal(t, rpcMethodEthCall, method) + call, _ := params[0].(map[string]any) + data, _ := call["data"].(string) + switch { + case strings.HasPrefix(data, wethTokenSelector): + return quoted(hexWord(0xabcd)), nil // WETH token address + case strings.HasPrefix(data, totalSupplySelector): + return quoted(hexWord(5000)), nil + } + return quoted("0x"), nil + }) + entry, err := fetchWETHBalance(context.Background(), srv.URL, common.HexToAddress("0xbridge"), "latest") + require.NoError(t, err) + require.NotNil(t, entry) + require.Equal(t, weth, entry.WrappedTokenAddress) + require.Equal(t, "5000", entry.Balance) +} + +func TestFetchWETHBalanceZeroAddress(t *testing.T) { + t.Parallel() + srv := newRPCStub(t, func(string, []any) (json.RawMessage, *jsonRPCError) { + return quoted(hexWord(0)), nil // zero WETH address → no entry + }) + entry, err := fetchWETHBalance(context.Background(), srv.URL, common.HexToAddress("0xbridge"), "latest") + require.NoError(t, err) + require.Nil(t, entry) +} + +func TestFetchTokenNameAndDecimalsExtra(t *testing.T) { + t.Parallel() + addr := common.HexToAddress("0xtoken") + + t.Run("success", func(t *testing.T) { + t.Parallel() + // ABI-encoded string "USDC": [offset=32][len=4]["USDC"...] + nameData := make([]byte, 96) + nameData[31] = 0x20 + nameData[63] = 4 + copy(nameData[64:], []byte("USDC")) + srv := newRPCStub(t, func(method string, params []any) (json.RawMessage, *jsonRPCError) { + require.Equal(t, rpcMethodEthCall, method) + call, _ := params[0].(map[string]any) + data, _ := call["data"].(string) + if strings.HasPrefix(data, abiSelectorName) { + return quoted("0x" + common.Bytes2Hex(nameData)), nil + } + return quoted(hexWord(18)), nil // decimals() + }) + require.Equal(t, "USDC", fetchTokenName(context.Background(), srv.URL, addr)) + require.Equal(t, uint8(18), fetchTokenDecimals(context.Background(), srv.URL, addr)) + }) + + t.Run("rpc error returns zero values", func(t *testing.T) { + t.Parallel() + srv := newRPCStub(t, func(string, []any) (json.RawMessage, *jsonRPCError) { + return nil, revertErr() + }) + require.Empty(t, fetchTokenName(context.Background(), srv.URL, addr)) + require.Equal(t, uint8(0), fetchTokenDecimals(context.Background(), srv.URL, addr)) + }) +} + +func TestIsRevertError(t *testing.T) { + t.Parallel() + require.True(t, isRevertError(&jsonRPCError{Code: 3})) + require.True(t, isRevertError(&jsonRPCError{Message: "execution reverted"})) + require.False(t, isRevertError(&jsonRPCError{Code: -32000, Message: "server error"})) +} + +func TestComputeNativeBalance(t *testing.T) { + t.Parallel() + url := newBatchRPCServer(t, func(method string, params []json.RawMessage) any { + switch method { + case rpcMethodEthGetBalance: + var tag string + _ = json.Unmarshal(params[1], &tag) + if tag == "0x0" { + return "0x64" // genesis balance 100 + } + return "0xa" // current balance 10 → unlocked native = 90 + default: + return "0x" // gasTokenNetwork/Address fail → defaults + } + }) + entry, err := computeNativeBalance(context.Background(), url, common.HexToAddress("0xbridge"), "latest") + require.NoError(t, err) + require.Equal(t, "90", entry.Balance) + require.Equal(t, common.Address{}, entry.WrappedTokenAddress) +} + +func TestMergeAddresses(t *testing.T) { + t.Parallel() + a := common.HexToAddress("0x01") + b := common.HexToAddress("0x02") + c := common.HexToAddress("0x03") + merged := mergeAddresses([]common.Address{a, b}, []common.Address{b, c, {}}) + require.ElementsMatch(t, []common.Address{a, b, c}, merged) +} + +func TestSaveJSONErrorBranches(t *testing.T) { + t.Parallel() + dir := t.TempDir() + + // Marshal failure: a channel cannot be JSON-encoded → logged, no panic, nothing written. + require.NotPanics(t, func() { saveJSON(dir, "bad.json", make(chan int)) }) + require.False(t, fileExists(filepath.Join(dir, "bad.json"))) + + // Write failure: using a regular file as the "directory" makes WriteFile fail. + notADir := filepath.Join(dir, "afile") + require.NoError(t, os.WriteFile(notADir, []byte("x"), 0o600)) + require.NotPanics(t, func() { saveJSON(notADir, "out.json", map[string]int{"a": 1}) }) +} + +func TestReceiptAddresses(t *testing.T) { + t.Parallel() + from := "0x1000000000000000000000000000000000000001" + to := "0x1000000000000000000000000000000000000002" + logAddr := "0x1000000000000000000000000000000000000003" + + srv := newRPCStub(t, func(method string, _ []any) (json.RawMessage, *jsonRPCError) { + require.Equal(t, "eth_getTransactionReceipt", method) + receipt := map[string]any{ + "from": from, "to": to, + "logs": []map[string]string{{"address": logAddr}}, + } + out, _ := json.Marshal(receipt) + return out, nil + }) + + addrs, err := receiptAddresses(context.Background(), srv.URL, common.HexToHash("0xabc")) + require.NoError(t, err) + require.ElementsMatch(t, + []common.Address{common.HexToAddress(from), common.HexToAddress(to), common.HexToAddress(logAddr)}, addrs) +} + +func TestReceiptAddressesNull(t *testing.T) { + t.Parallel() + srv := newRPCStub(t, func(string, []any) (json.RawMessage, *jsonRPCError) { + return json.RawMessage(`null`), nil + }) + _, err := receiptAddresses(context.Background(), srv.URL, common.HexToHash("0xabc")) + require.ErrorContains(t, err, "is null") +} + +func TestRunStepA2(t *testing.T) { + t.Parallel() + + t.Run("no failed traces", func(t *testing.T) { + t.Parallel() + res, err := RunStepA2(context.Background(), &Config{}, nil) + require.NoError(t, err) + require.Empty(t, res.Addresses) + }) + + t.Run("recovers addresses from receipts", func(t *testing.T) { + t.Parallel() + from := "0x1000000000000000000000000000000000000001" + srv := newRPCStub(t, func(string, []any) (json.RawMessage, *jsonRPCError) { + out, _ := json.Marshal(map[string]any{"from": from}) + return out, nil + }) + cfg := &Config{L2RPCURL: srv.URL, Options: Options{ConcurrencyLimit: 2}} + res, err := RunStepA2(context.Background(), cfg, []FailedTrace{{Hash: common.HexToHash("0xabc")}}) + require.NoError(t, err) + require.Equal(t, []common.Address{common.HexToAddress(from)}, res.Addresses) + }) +} + +func TestFetchL2ChainID(t *testing.T) { + t.Parallel() + srv := newRPCStub(t, func(method string, _ []any) (json.RawMessage, *jsonRPCError) { + require.Equal(t, "eth_chainId", method) + return quoted("0x1a4"), nil + }) + id, err := fetchL2ChainID(context.Background(), srv.URL) + require.NoError(t, err) + require.Equal(t, uint64(420), id) +} + +func TestBuildHolderBridgeExits(t *testing.T) { + t.Parallel() + stepC := &StepCResult{HolderBridges: []HolderBridge{ + {OriginNetwork: 1, OriginTokenAddress: common.HexToAddress("0xaa"), + HolderAddress: common.HexToAddress("0xbb"), Amount: "100"}, + {OriginNetwork: 1, OriginTokenAddress: common.HexToAddress("0xcc"), + HolderAddress: common.HexToAddress("0xdd"), Amount: "0"}, // zero → skipped + }} + exits := buildHolderBridgeExits(stepC, 0) + require.Len(t, exits, 1) + require.Equal(t, common.HexToAddress("0xbb"), exits[0].DestinationAddress) +} + +func TestAnvilForkBackendWrappersExtra(t *testing.T) { + t.Parallel() + bridge := common.HexToAddress("0xbridge") + token := common.HexToAddress("0xtoken") + + rootOut, err := bridgeABI.Methods["getRoot"].Outputs.Pack([32]byte{}) + require.NoError(t, err) + metaOut, err := bridgeABI.Methods["getTokenMetadata"].Outputs.Pack([]byte{0x07}) + require.NoError(t, err) + gasMetaOut, err := bridgeABI.Methods["gasTokenMetadata"].Outputs.Pack([]byte{}) + require.NoError(t, err) + wrappedOut, err := bridgeABI.Methods["getTokenWrappedAddress"].Outputs.Pack(common.HexToAddress("0xbeef")) + require.NoError(t, err) + + getRootSel := selectorHex(bridgeABI, "getRoot") + tokenMetaSel := selectorHex(bridgeABI, "getTokenMetadata") + gasMetaSel := selectorHex(bridgeABI, "gasTokenMetadata") + wrappedSel := selectorHex(bridgeABI, "getTokenWrappedAddress") + + srv := newRPCStub(t, func(method string, params []any) (json.RawMessage, *jsonRPCError) { + if method == "anvil_setBalance" { + return quoted("0x1"), nil + } + call, _ := params[0].(map[string]any) + data, _ := call["data"].(string) + data = strings.TrimPrefix(data, "0x") + switch { + case strings.HasPrefix(data, getRootSel): + return hexResult(rootOut), nil + case strings.HasPrefix(data, tokenMetaSel): + return hexResult(metaOut), nil + case strings.HasPrefix(data, gasMetaSel): + return hexResult(gasMetaOut), nil + case strings.HasPrefix(data, wrappedSel): + return hexResult(wrappedOut), nil + } + return quoted("0x"), nil + }) + + b := &anvilForkBackend{url: srv.URL, bridgeAddr: bridge} + ctx := context.Background() + + root, err := b.LocalExitRoot(ctx, "latest") + require.NoError(t, err) + require.Equal(t, common.Hash{}, root) + + meta, err := b.TokenMetadata(ctx, token) + require.NoError(t, err) + require.Equal(t, []byte{0x07}, meta) + + gasMeta, err := b.GasTokenMetadata(ctx) + require.NoError(t, err) + require.Empty(t, gasMeta) + + wrapped, err := b.TokenWrappedAddress(ctx, 1, token) + require.NoError(t, err) + require.Equal(t, common.HexToAddress("0xbeef"), wrapped) + + require.NoError(t, b.SetSenderBalance(ctx, common.HexToAddress("0xsender"))) +} diff --git a/tools/exit_certificate/filenames.go b/tools/exit_certificate/filenames.go new file mode 100644 index 000000000..7d4bf8a6a --- /dev/null +++ b/tools/exit_certificate/filenames.go @@ -0,0 +1,52 @@ +package exit_certificate + +// Output filenames written/read by the pipeline steps, all relative to options.outputDir. +// Centralized here so each name has a single source of truth (no duplicated string literals). +const ( + fileFinalCertificate = "exit-certificate-final.json" + fileSignedCertificate = "exit-certificate-signed.json" + + fileStep0TargetBlock = "step-0-l2_target_block.json" + fileStep0LBT = "step-0-lbt.json" + + fileStepAAddresses = "step-a-addresses.json" + fileStepAFailedTraces = "step-a-failed-traces.json" + fileStepA1Addresses = "step-a1-addresses.json" + fileStepA1FailedTrace = "step-a1-failed-traces.json" + fileStepA2Addresses = "step-a2-addresses.json" + + fileStepBAccumulated = "step-b-accumulated.json" + fileStepBContractAddresses = "step-b-contract-addresses.json" + fileStepBEOABalances = "step-b-eoa-balances.json" + fileStepB2DetectedERC20s = "step-b2-detected-erc20s.json" + fileStepB2DiscardedERC20s = "step-b2-discarded-erc20s.json" + fileStepB3ERC20Holders = "step-b3-erc20-holders.json" + + fileStepCSCLockedValues = "step-c-sc-locked-values.json" + fileStepCHolderBridges = "step-c-holder-bridges.json" + + fileStepCheckResult = "step-check-result.json" + + fileStepDCertificate = "step-d-exit-certificate.json" + + fileStepECertificate = "step-e-exit-certificate.json" + fileStepEUnclaimedBridges = "step-e-unclaimed-bridges.json" + fileStepEUnclaimedMsgs = "step-e-unclaimed-messages.json" + + fileStepFCappedCertificate = "step-f-capped-certificate.json" + fileStepFChecks = "step-f-checks.json" + //nolint:gosec // G101 false positive: this is an output filename, not a credential. + fileStepFTokenBalances = "step-f-token-balances.json" + + fileStepG1ShadowForkBlock = "step-g1-shadow-fork-block.json" + fileStepG1LiteDB = "step-g1-l2bridgesyncerlite.sqlite" + fileStepGNewLocalExitRoot = "step-g-new-local-exit-root.json" + fileStepGReorderedCertificate = "step-g-reordered-certificate.json" + fileStepGFailedExit = "step-g-failed-exit.json" + fileStepGLiteDB = "step-g-l2bridgesyncerlite.sqlite" + + fileStepHPreviousLocalExitRoot = "step-h-previous-local-exit-root.json" + + fileStepSubmitResult = "step-submit-result.json" + fileStepWaitResult = "step-wait-result.json" +) diff --git a/tools/exit_certificate/hex.go b/tools/exit_certificate/hex.go new file mode 100644 index 000000000..84a949bbd --- /dev/null +++ b/tools/exit_certificate/hex.go @@ -0,0 +1,90 @@ +package exit_certificate + +import ( + "fmt" + "math" + "math/big" + "strings" +) + +const ( + hexBase = 16 + decimalBase = 10 + hexLetterOffset = 10 + maxMetadataSize = 1 << 20 // 1 MB + + abiWordBytes = 32 // EVM ABI word size in bytes + twoABIWords = 64 // two ABI words (offset + length header for dynamic types) + fourABIWords = 128 // four ABI words (error decoder minimum size) + splitInTwo = 2 // used with strings.SplitN + bridgeEventFields = 8 // number of fields in the BridgeEvent log + ethDecimals = 18 // standard ETH/ERC-20 decimal precision + minTopicsForLeaf = 2 // minimum topics required to extract leaf count + uncheckedStatus = "unchecked" +) + +// safeUint32 converts a big.Int to uint32, returning an error on overflow. +func safeUint32(val *big.Int) (uint32, error) { + if !val.IsUint64() || val.Uint64() > math.MaxUint32 { + return 0, fmt.Errorf("value %s overflows uint32", val) + } + return uint32(val.Uint64()), nil +} + +// safeUint8 converts a big.Int to uint8, returning an error on overflow. +func safeUint8(val *big.Int) (uint8, error) { + if !val.IsUint64() || val.Uint64() > math.MaxUint8 { + return 0, fmt.Errorf("value %s overflows uint8", val) + } + return uint8(val.Uint64()), nil +} + +// hexToUint64 parses a hex string (with or without 0x prefix) to uint64. +func hexToUint64(s string) uint64 { + s = strings.TrimPrefix(s, "0x") + s = strings.TrimPrefix(s, "0X") + var n uint64 + for _, c := range s { + n <<= 4 + switch { + case c >= '0' && c <= '9': + n |= uint64(c - '0') + case c >= 'a' && c <= 'f': + n |= uint64(c - 'a' + hexLetterOffset) + case c >= 'A' && c <= 'F': + n |= uint64(c - 'A' + hexLetterOffset) + } + } + return n +} + +// hexToBigInt parses a 0x-prefixed hex string to a *big.Int. Returns zero on empty/invalid input. +func hexToBigInt(s string) *big.Int { + s = strings.TrimPrefix(s, "0x") + s = strings.TrimPrefix(s, "0X") + if s == "" { + return new(big.Int) + } + n, ok := new(big.Int).SetString(s, hexBase) + if !ok { + return new(big.Int) + } + return n +} + +// toBlockTag formats a block number as a 0x-prefixed hex string for use in RPC calls. +func toBlockTag(blockNum uint64) string { + return fmt.Sprintf("0x%x", blockNum) +} + +// parseDecimalBigInt parses a decimal string to *big.Int. Returns zero on empty/invalid input. +func parseDecimalBigInt(s string) *big.Int { + if s == "" { + return new(big.Int) + } + n, ok := new(big.Int).SetString(s, decimalBase) + if !ok { + return new(big.Int) + } + return n +} diff --git a/tools/exit_certificate/hex_test.go b/tools/exit_certificate/hex_test.go new file mode 100644 index 000000000..84490bad0 --- /dev/null +++ b/tools/exit_certificate/hex_test.go @@ -0,0 +1,75 @@ +package exit_certificate + +import ( + "math" + "math/big" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestToBlockTag(t *testing.T) { + t.Parallel() + require.Equal(t, "0x0", toBlockTag(0)) + require.Equal(t, "0x1", toBlockTag(1)) + require.Equal(t, "0x64", toBlockTag(100)) + require.Equal(t, "0xff", toBlockTag(255)) + require.Equal(t, "0x100", toBlockTag(256)) +} + +func TestParseDecimalBigInt_Valid(t *testing.T) { + t.Parallel() + require.Equal(t, big.NewInt(12345), parseDecimalBigInt("12345")) +} + +func TestParseDecimalBigInt_Empty(t *testing.T) { + t.Parallel() + require.Equal(t, new(big.Int), parseDecimalBigInt("")) +} + +func TestParseDecimalBigInt_Invalid(t *testing.T) { + t.Parallel() + require.Equal(t, new(big.Int), parseDecimalBigInt("not-a-number")) +} + +func TestSafeUint32_OK(t *testing.T) { + t.Parallel() + v, err := safeUint32(big.NewInt(42)) + require.NoError(t, err) + require.Equal(t, uint32(42), v) +} + +func TestSafeUint32_MaxValue(t *testing.T) { + t.Parallel() + v, err := safeUint32(new(big.Int).SetUint64(math.MaxUint32)) + require.NoError(t, err) + require.Equal(t, uint32(math.MaxUint32), v) +} + +func TestSafeUint32_Overflow(t *testing.T) { + t.Parallel() + _, err := safeUint32(new(big.Int).SetUint64(math.MaxUint32 + 1)) + require.Error(t, err) + require.Contains(t, err.Error(), "overflows uint32") +} + +func TestSafeUint8_OK(t *testing.T) { + t.Parallel() + v, err := safeUint8(big.NewInt(200)) + require.NoError(t, err) + require.Equal(t, uint8(200), v) +} + +func TestSafeUint8_MaxValue(t *testing.T) { + t.Parallel() + v, err := safeUint8(big.NewInt(math.MaxUint8)) + require.NoError(t, err) + require.Equal(t, uint8(math.MaxUint8), v) +} + +func TestSafeUint8_Overflow(t *testing.T) { + t.Parallel() + _, err := safeUint8(big.NewInt(256)) + require.Error(t, err) + require.Contains(t, err.Error(), "overflows uint8") +} diff --git a/tools/exit_certificate/integration_test.go b/tools/exit_certificate/integration_test.go new file mode 100644 index 000000000..a530f5451 --- /dev/null +++ b/tools/exit_certificate/integration_test.go @@ -0,0 +1,151 @@ +package exit_certificate + +import ( + "math/big" + "os" + "testing" + + agglayertypes "github.com/agglayer/aggkit/agglayer/types" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +// TestLoadParametersJSON loads the actual parameters.json used in production +// and validates that the config is parsed correctly. +func TestLoadParametersJSON(t *testing.T) { + t.Parallel() + + configPath := "../../../exit-certificate-tool/parameters.json" + if _, err := os.Stat(configPath); os.IsNotExist(err) { + t.Skip("parameters.json not found at expected path — skipping integration test") + } + + cfg, err := LoadConfig(configPath) + require.NoError(t, err) + + require.NotEmpty(t, cfg.L2RPCURL) + require.NotEmpty(t, cfg.L1RPCURL) + require.NotEqual(t, common.Address{}, cfg.L2BridgeAddress) + require.NotEqual(t, common.Address{}, cfg.L1BridgeAddress) + require.NotEmpty(t, cfg.TargetBlock) + require.Greater(t, cfg.Options.BlockRange, 0) + require.Greater(t, cfg.Options.ConcurrencyLimit, 0) + require.Greater(t, cfg.Options.RPCBatchSize, 0) +} + +// TestStepD_WithProductionLikeData tests Step D with data structures matching +// the format that a real run would produce. +func TestStepD_WithProductionLikeData(t *testing.T) { + t.Parallel() + + ethBalance, _ := new(big.Int).SetString("5000000000000000000", 10) + tokenBalance, _ := new(big.Int).SetString("1000000000", 10) + + cfg := &Config{ + L2NetworkID: 1, + ExitAddress: common.HexToAddress("0x0000000000000000000000000000000000000001"), + DestinationNetwork: 0, + } + + stepB := &StepBResult{ + EOABalances: []EOABalance{ + { + Address: common.HexToAddress("0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"), + ETHBalance: ethBalance.String(), + Tokens: []EOATokenBalance{ + { + WrappedTokenAddress: common.HexToAddress("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"), + OriginNetwork: 0, + OriginTokenAddress: common.HexToAddress("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"), + Balance: tokenBalance.String(), + }, + }, + }, + { + Address: common.HexToAddress("0x1234567890123456789012345678901234567890"), + ETHBalance: "100000000000000000", + }, + }, + } + + stepC := &StepCResult{ + SCLockedValues: []SCLockedValue{ + { + WrappedTokenAddress: common.HexToAddress("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"), + OriginNetwork: 0, + OriginTokenAddress: common.HexToAddress("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"), + LBTBalance: "5000000000", + EOAAccumulated: "1000000000", + PendingSCLockedBalance: "4000000000", + }, + }, + } + + result, err := RunStepD(cfg, stepB, stepC) + require.NoError(t, err) + require.NotNil(t, result.Certificate) + + // 2 EOAs (1 ETH + 1 token each for first, 1 ETH for second) + 1 SC-locked = 4 + require.Len(t, result.Certificate.BridgeExits, 4) + require.Equal(t, uint32(1), result.Certificate.NetworkID) + require.Equal(t, uint64(0), result.Certificate.Height) + require.Equal(t, common.Hash{}, result.Certificate.PrevLocalExitRoot) + require.Equal(t, common.Hash{}, result.Certificate.NewLocalExitRoot) + + // Verify EOA exits + exit0 := result.Certificate.BridgeExits[0] + require.Equal(t, common.HexToAddress("0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"), exit0.DestinationAddress) + require.Equal(t, ethBalance, exit0.Amount) + require.Equal(t, common.Address{}, exit0.TokenInfo.OriginTokenAddress) + + exit1 := result.Certificate.BridgeExits[1] + require.Equal(t, common.HexToAddress("0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"), exit1.DestinationAddress) + require.Equal(t, tokenBalance, exit1.Amount) + require.Equal(t, common.HexToAddress("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"), exit1.TokenInfo.OriginTokenAddress) + + // Verify SC-locked exit goes to exit address + exit3 := result.Certificate.BridgeExits[3] + require.Equal(t, common.HexToAddress("0x0000000000000000000000000000000000000001"), exit3.DestinationAddress) + scAmount, _ := new(big.Int).SetString("4000000000", 10) + require.Equal(t, scAmount, exit3.Amount) +} + +// TestStepE_WithProductionLikeData tests Step E filtering with a simulated claimed set. +func TestStepE_WithProductionLikeData(t *testing.T) { + t.Parallel() + + cert := createTestCertificate(t, 1, 2) + + // Simulate 3 L1 deposits targeting L2, with deposit counts 0, 1, 2 + deposits := []L1Deposit{ + {DepositCount: 0, Amount: big.NewInt(1000)}, + {DepositCount: 1, Amount: big.NewInt(2000)}, + {DepositCount: 2, Amount: big.NewInt(5000), DestinationAddress: common.HexToAddress("0x1234")}, + } + + // Simulate isClaimed results: deposits 0 and 1 are claimed + claimedSet := map[uint32]struct{}{0: {}, 1: {}} + unclaimed := filterUnclaimedDeposits(deposits, claimedSet) + require.Len(t, unclaimed, 1) + require.Equal(t, uint32(2), unclaimed[0].DepositCount) + + require.Len(t, cert.BridgeExits, 2) +} + +func createTestCertificate(t *testing.T, networkID uint32, numExits int) *agglayertypes.Certificate { + t.Helper() + + exits := make([]*agglayertypes.BridgeExit, numExits) + for i := range numExits { + exits[i] = MakeBridgeExit( + 0, common.Address{}, 0, + common.HexToAddress("0x1111"), + big.NewInt(int64(1000*(i+1))), + ) + } + + return &agglayertypes.Certificate{ + NetworkID: networkID, + BridgeExits: exits, + } +} diff --git a/tools/exit_certificate/parameters.json.example b/tools/exit_certificate/parameters.json.example new file mode 100644 index 000000000..2b8d6c5cd --- /dev/null +++ b/tools/exit_certificate/parameters.json.example @@ -0,0 +1,44 @@ +{ + "l2RpcUrl": "https://your-l2-rpc.example.com", + "l1RpcUrl": "https://your-l1-rpc.example.com", + "l2BridgeAddress": "0x2a3DD3EB832aF982ec71669E178424b10Dca2EDe", + "l1BridgeAddress": "0x2a3DD3EB832aF982ec71669E178424b10Dca2EDe", + "l2NetworkId": 1, + "targetBlock": "LatestBlock", + "exitAddress": "0x0000000000000000000000000000000000000001", + "destinationNetwork": 0, + "sovereignRollupAddr": "", + "l1GlobalExitRootAddress": "", + "rollupManagerAddress": "", + "options": { + "blockRange": 10000, + "stepAWindowSize": 150000, + "concurrencyLimit": 200, + "rpcBatchSize": 200, + "rpcDelayMs": 10, + "outputDir": "./output", + "l1StartBlock": 0, + "ignoreOnTraceError": false, + "ignoreGenesisBalance": false, + "ignoreUnclaimed": false, + "ignoreBalanceMismatch": false, + "verifyNewLocalExitRootUsingShadowFork": true, + "extraErc20Contracts": [], + "bridgeServiceURL": "", + "bridgeServiceType": "aggkit", + "agglayerClient": { + "GRPC": { + "URL": "", + "UseTLS": false + } + }, + "useAgglayerAdminToStepFCheck": true, + "agglayerAdminURL": "", + "agglayerAdminToken": "" + }, + "signerConfig": { + "Method": "local", + "Path": "/path/to/keystore.json", + "Password": "" + } +} diff --git a/tools/exit_certificate/parameters.toml.example b/tools/exit_certificate/parameters.toml.example new file mode 100644 index 000000000..4c6233f58 --- /dev/null +++ b/tools/exit_certificate/parameters.toml.example @@ -0,0 +1,136 @@ +# exit_certificate — example TOML config. +# Copy to parameters.toml and fill in your values. Field names match the JSON config exactly. +# The format is selected by file extension: .toml is parsed as TOML, anything else as JSON. + +# L2 RPC endpoint. REQUIRED. Must expose debug_traceTransaction (archive node) for Step A. +l2RpcUrl = "https://your-l2-rpc.example.com" + +# L1 RPC endpoint. Optional, but REQUIRED by Step E (unclaimed deposits), Step I +# (L1InfoTreeLeafCount) and Step CHECK. Steps that need it are skipped/fail without it. +l1RpcUrl = "https://your-l1-rpc.example.com" + +# L2 bridge contract address. REQUIRED. +l2BridgeAddress = "0x2a3DD3EB832aF982ec71669E178424b10Dca2EDe" + +# L1 bridge contract address. Optional. Default: same as l2BridgeAddress. +l1BridgeAddress = "0x2a3DD3EB832aF982ec71669E178424b10Dca2EDe" + +# L2 network ID, validated against the bridge contract in Step CHECK. Default: 1. +l2NetworkId = 1 + +# Block up to which L2 state is scanned. REQUIRED. +# Accepts a finality keyword (LatestBlock, FinalizedBlock, SafeBlock, PendingBlock) with an optional +# negative offset (e.g. "LatestBlock/-10"), a decimal ("21000000") or hex ("0x1406f40") block number. +# Empty string defaults to LatestBlock. Resolved to a concrete number at the start of Step 0. +targetBlock = "LatestBlock" + +# Destination address on destinationNetwork for SC-locked value (tokens not held by any EOA). +# REQUIRED. Must be an address whose private key you control and must NOT be the zero address +# (0x00..00) — these funds can only be recovered by signing from it. LoadConfig rejects both. +# A multisig (e.g. a Gnosis Safe) is strongly recommended over a single EOA for better security. +exitAddress = "0x0000000000000000000000000000000000000001" + +# Destination network ID for the generated bridge exits. Default: 0 (L1/mainnet). +destinationNetwork = 0 + +# Address of the aggchainbase contract on L1. REQUIRED by Step CHECK (network-type, threshold and +# gas-token checks). Step CHECK fails if unset. +sovereignRollupAddr = "" + +# Address of the PolygonZkEVMGlobalExitRootV2 contract on L1. REQUIRED by Step I to read the +# L1InfoTreeLeafCount from UpdateL1InfoTreeV2 events. Step I fails if unset. +l1GlobalExitRootAddress = "" + +# OPTIONAL — address of the PolygonRollupManager (AgglayerManager) contract on L1. Used by Step WAIT +# to confirm the certificate's L1 settlement via the VerifyBatchesTrustedAggregator event. If unset +# it is resolved on-chain from sovereignRollupAddr.rollupManager(); Step WAIT errors when neither this +# nor sovereignRollupAddr is set. +rollupManagerAddress = "" + +[options] +# Number of blocks per log query (e.g. BridgeEvent scans). Default: 5000. +blockRange = 10000 + +# Blocks loaded into memory at once during Step A (debug_traceTransaction). Default: 150000. +stepAWindowSize = 150000 + +# Max concurrent RPC batches / worker count. Default: 20. +concurrencyLimit = 200 + +# Number of calls per JSON-RPC batch. Default: 200. +rpcBatchSize = 200 + +# Sleep in milliseconds inserted between RPC batches (rate-limiting). Default: 0 (no delay). +rpcDelayMs = 10 + +# Directory for intermediate and final output files. Relative paths resolve from the config file +# directory. Default: "output". +outputDir = "./output" + +# First L1 block to scan in Step E. Default: 0 (genesis). +l1StartBlock = 0 + +# First L2 block to scan. Default: 0 (genesis). +l2StartBlock = 0 + +# Skip a transaction whose debug_traceTransaction fails in Step A instead of aborting (failed hashes +# are saved to step-a-failed-traces.json). Default: false. +ignoreOnTraceError = false + +# Downgrade the genesis-balance guard (non-zero ETH balance at block 0) from an abort to a warning. +# Enable only for Kurtosis/test environments. Default: false. +ignoreGenesisBalance = false + +# Skip adding unclaimed L1->L2 deposits to the certificate in Step E (still detected and warned). +# Default: false. +ignoreUnclaimed = false + +# Do not abort Step F on token balance mismatches; instead produce step-f-capped-certificate.json +# with mismatched exits scaled down to min(agglayer, lbt). Default: false. +ignoreBalanceMismatch = false + +# Make the Step G lite syncer warn and continue (instead of aborting) on L2 events that would +# invalidate a BridgeEvent-only reconstruction. The computed NewLocalExitRoot may then be incorrect. +# Default: false. +ignoreUnsupportedL2Events = false + +# When true (default), Step G2 spins up an Anvil shadow-fork, replays every exit against the real +# bridge contract and verifies the NewLocalExitRoot against getRoot() (requires anvil in $PATH). +# When false, the NewLocalExitRoot is computed off-chain (faster, no Anvil, trusts leaf encoding). +# Default: true. +verifyNewLocalExitRootUsingShadowFork = true + +# Optional list of extra ERC-20 contract addresses whose holders are decomposed in Step B3. +# Default: [] (empty). +extraErc20Contracts = [] + +# Base URL of the bridge service REST API. When set, Step E cross-checks unclaimed deposits and +# errors on discrepancies. Default: "" (disabled). +bridgeServiceURL = "" + +# Bridge service API flavour for the cross-check: "aggkit" or "zkevm". Default: "aggkit". +bridgeServiceType = "aggkit" + +# When true (default), Step F runs the agglayer admin balance check (three-way: LBT == agglayer == +# certificate). When false, it skips the agglayer query and compares LBT (step 0) vs certificate sums +# offline (no agglayerAdminURL needed; skipped only if no LBT data exists). +useAgglayerAdminToStepFCheck = true + +# Agglayer admin RPC URL. REQUIRED by Step F in agglayer mode (admin_getTokenBalance). Not needed when +# useAgglayerAdminToStepFCheck = false. +agglayerAdminURL = "" + +# Optional Bearer token for agglayerAdminURL (e.g. when protected by Google Cloud IAP). Default: "". +agglayerAdminToken = "" + +# Agglayer gRPC client config. REQUIRED by Steps H, SUBMIT and WAIT. +[options.agglayerClient.GRPC] +URL = "" +UseTLS = false + +# Certificate signer (same format as aggsender's AggsenderPrivateKey). Used by Step SIGN. +# In "all" mode Step SIGN is skipped when Method is unset; in single-step mode it errors. +[signerConfig] +Method = "local" +Path = "/path/to/keystore.json" +Password = "" diff --git a/tools/exit_certificate/rpc.go b/tools/exit_certificate/rpc.go new file mode 100644 index 000000000..a3ed73dad --- /dev/null +++ b/tools/exit_certificate/rpc.go @@ -0,0 +1,380 @@ +package exit_certificate + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "math" + "net/http" + "net/url" + "strings" + "time" + + "github.com/agglayer/aggkit/log" +) + +const ( + defaultRetries = 3 + maxBackoffMs = 10000 + baseBackoffMs = 1000 + backoffExponent = 2 + idleConnTimeoutSec = 90 + httpTimeoutSec = 120 + maxIdleConnsPerHost = 100 + // eip1474RevertCode is the JSON-RPC error code for a contract revert per EIP-1474. + eip1474RevertCode = 3 +) + +// httpClient keeps a large per-host idle connection pool to avoid throttling +// parallel RPC traffic on Go's default MaxIdleConnsPerHost=2. +var httpClient = &http.Client{ + Transport: &http.Transport{ + MaxIdleConns: 0, + MaxIdleConnsPerHost: maxIdleConnsPerHost, + MaxConnsPerHost: 0, + IdleConnTimeout: idleConnTimeoutSec * time.Second, + }, + Timeout: httpTimeoutSec * time.Second, +} + +type jsonRPCRequest struct { + JSONRPC string `json:"jsonrpc"` + Method string `json:"method"` + Params any `json:"params"` + ID int `json:"id"` +} + +type jsonRPCResponse struct { + JSONRPC string `json:"jsonrpc"` + Result json.RawMessage `json:"result"` + Error *jsonRPCError `json:"error"` + ID int `json:"id"` +} + +type jsonRPCError struct { + Code int `json:"code"` + Message string `json:"message"` + Data string `json:"data"` +} + +// RPCExecutionError is returned by singleRPC when the node returns an RPC-level error. +// Data holds the raw hex-encoded revert payload (e.g. ABI-encoded custom error). +type RPCExecutionError struct { + Code int + Message string + Data string +} + +func (e *RPCExecutionError) Error() string { + if e.Data != "" { + return fmt.Sprintf("RPC error: %s (data: %s)", e.Message, e.Data) + } + return fmt.Sprintf("RPC error: %s", e.Message) +} + +// RPCCall represents a single JSON-RPC method call. +type RPCCall struct { + Method string + Params []any +} + +// isRevertError returns true for errors that represent a contract revert — +// code 3 per EIP-1474, or any message containing "revert". These should not +// be retried because the same call will revert again. +func isRevertError(e *jsonRPCError) bool { + if e.Code == eip1474RevertCode { + return true + } + return strings.Contains(strings.ToLower(e.Message), "revert") +} + +// batchRPC sends a batch of JSON-RPC calls in a single HTTP POST. +// Returns ordered results matching the input calls slice. Per-item RPC errors are retried +// up to retries times. Returns an error if any call still fails after all retries. +func batchRPC(ctx context.Context, url string, calls []RPCCall, retries int) ([]json.RawMessage, error) { + if retries <= 0 { + retries = defaultRetries + } + + results := make([]json.RawMessage, len(calls)) + pendingIdxs := make([]int, len(calls)) + for i := range pendingIdxs { + pendingIdxs[i] = i + } + var permanentlyFailed []int + + for attempt := 1; attempt <= retries && len(pendingIdxs) > 0; attempt++ { + if ctx.Err() != nil { + return nil, ctx.Err() + } + if attempt > 1 { + log.Warnf("batchRPC: retrying %d/%d failed calls (attempt %d/%d)", + len(pendingIdxs), len(calls), attempt, retries) + sleepWithBackoff(ctx, attempt-1) + } + + subCalls := make([]RPCCall, len(pendingIdxs)) + for i, origIdx := range pendingIdxs { + subCalls[i] = calls[origIdx] + } + + requests := make([]jsonRPCRequest, len(subCalls)) + for i, c := range subCalls { + requests[i] = jsonRPCRequest{JSONRPC: "2.0", Method: c.Method, Params: c.Params, ID: i + 1} + } + + body, err := json.Marshal(requests) + if err != nil { + return nil, fmt.Errorf("marshal batch request: %w", err) + } + + responses, err := doRPCWithRetry(ctx, url, body, 1, "") + if err != nil { + if attempt == retries { + return nil, err + } + log.Warnf("batchRPC attempt %d/%d HTTP error: %v", attempt, retries, err) + continue + } + + // Whole-batch rejection: node returned a single error object for multiple pending calls. + if len(responses) == 1 && responses[0].Error != nil && len(subCalls) > 1 { + e := responses[0].Error + if attempt == retries { + return nil, &RPCExecutionError{Code: e.Code, Message: e.Message, Data: e.Data} + } + log.Warnf("batchRPC attempt %d/%d: node rejected batch of %d calls [%d] %s — retrying", + attempt, retries, len(subCalls), e.Code, e.Message) + continue + } + + if len(responses) != len(subCalls) { + return nil, fmt.Errorf("RPC response count %d does not match request count %d", + len(responses), len(subCalls)) + } + + var nextPending []int + for _, r := range responses { + localIdx := r.ID - 1 + if localIdx < 0 || localIdx >= len(pendingIdxs) { + continue + } + origIdx := pendingIdxs[localIdx] + if r.Error != nil { + if isRevertError(r.Error) { + log.Warnf("RPC call %s id=%d reverted (not retrying): [%d] %s", + calls[origIdx].Method, origIdx+1, r.Error.Code, r.Error.Message) + permanentlyFailed = append(permanentlyFailed, origIdx) + continue + } + log.Warnf("RPC error for %s id=%d (attempt %d/%d): [%d] %s", + calls[origIdx].Method, origIdx+1, attempt, retries, r.Error.Code, r.Error.Message) + nextPending = append(nextPending, origIdx) + continue + } + results[origIdx] = r.Result + } + pendingIdxs = nextPending + } + + if len(permanentlyFailed) > 0 { + return nil, fmt.Errorf("batchRPC: %d/%d calls reverted (not retried)", len(permanentlyFailed), len(calls)) + } + if len(pendingIdxs) > 0 { + return nil, fmt.Errorf("batchRPC: %d/%d calls still failing after %d attempts", + len(pendingIdxs), len(calls), retries) + } + + return results, nil +} + +// singleRPC sends one JSON-RPC call. Uses the same HTTP transport as batchRPC +// but propagates RPC-level errors as Go errors. +func singleRPC(ctx context.Context, url, method string, params []any, retries int) (json.RawMessage, error) { + return singleRPCAuth(ctx, url, method, params, retries, "") +} + +// singleRPCAuth is like singleRPC but adds an Authorization: Bearer header when bearerToken is non-empty. +// Use this for endpoints protected by Google Cloud IAP or similar token-based auth. +func singleRPCAuth( + ctx context.Context, url, method string, params []any, retries int, bearerToken string, +) (json.RawMessage, error) { + if retries <= 0 { + retries = defaultRetries + } + + body, err := json.Marshal(jsonRPCRequest{JSONRPC: "2.0", Method: method, Params: params, ID: 1}) + if err != nil { + return nil, fmt.Errorf("marshal request: %w", err) + } + + responses, err := doRPCWithRetry(ctx, url, body, retries, bearerToken) + if err != nil { + return nil, err + } + if len(responses) == 0 { + return nil, fmt.Errorf("RPC call %s returned empty response", method) + } + if responses[0].Error != nil { + rpcErr := responses[0].Error + return nil, &RPCExecutionError{Code: rpcErr.Code, Message: rpcErr.Message, Data: rpcErr.Data} + } + return responses[0].Result, nil +} + +func doRPCAttempt(ctx context.Context, url string, body []byte, bearerToken string) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("create HTTP request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + if bearerToken != "" { + req.Header.Set("Authorization", "Bearer "+bearerToken) + } + + resp, err := httpClient.Do(req) + if err != nil { + return nil, err + } + + respBody, err := io.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + return nil, err + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(respBody)) + } + + return respBody, nil +} + +// httpGetJSON performs a GET request to the given URL and returns the response body. +func httpGetJSON(ctx context.Context, reqURL string) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) + if err != nil { + return nil, fmt.Errorf("create GET request: %w", err) + } + req.Header.Set("Accept", "application/json") + + resp, err := httpClient.Do(req) + if err != nil { + return nil, err + } + + body, err := io.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + return nil, err + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body)) + } + return body, nil +} + +func parseRPCResponse(data []byte) ([]jsonRPCResponse, error) { + var responses []jsonRPCResponse + if err := json.Unmarshal(data, &responses); err != nil { + var single jsonRPCResponse + if err2 := json.Unmarshal(data, &single); err2 == nil { + return []jsonRPCResponse{single}, nil + } + return nil, fmt.Errorf("parse RPC response: %w", err) + } + return responses, nil +} + +// maskRPCURL returns only scheme://host to avoid exposing API keys in path segments. +func maskRPCURL(rawURL string) string { + u, err := url.Parse(rawURL) + if err != nil || u.Host == "" { + return rawURL + } + return u.Scheme + "://" + u.Host +} + +// doRPCWithRetry handles the HTTP POST + retry loop. +func doRPCWithRetry( + ctx context.Context, rpcURL string, body []byte, retries int, bearerToken string, +) ([]jsonRPCResponse, error) { + var lastErr error + for attempt := 1; attempt <= retries; attempt++ { + if ctx.Err() != nil { + return nil, ctx.Err() + } + respBody, err := doRPCAttempt(ctx, rpcURL, body, bearerToken) + if err != nil { + lastErr = err + if attempt < retries { + sleepWithBackoff(ctx, attempt) + continue + } + return nil, fmt.Errorf("RPC failed after %d attempts on %s: %w", retries, maskRPCURL(rpcURL), lastErr) + } + return parseRPCResponse(respBody) + } + return nil, fmt.Errorf("RPC failed after %d attempts on %s", retries, maskRPCURL(rpcURL)) +} + +func sleepWithBackoff(ctx context.Context, attempt int) { + ms := math.Min( + float64(baseBackoffMs*int(math.Pow(backoffExponent, float64(attempt)))), + float64(maxBackoffMs), + ) + select { + case <-time.After(time.Duration(ms) * time.Millisecond): + case <-ctx.Done(): + } +} + +// indexedBatchResult pairs batch RPC results with their offset in the global slice. +type indexedBatchResult struct { + offset int + results []json.RawMessage +} + +// concurrentBatchRPC splits calls into batchSize chunks and processes them +// through a worker pool. Workers immediately pick up the next batch when done. +func concurrentBatchRPC( + ctx context.Context, url string, allCalls []RPCCall, + batchSize, concurrency int, label string, +) ([]json.RawMessage, error) { + if len(allCalls) == 0 { + return nil, nil + } + + type batchJob struct { + offset int + calls []RPCCall + } + + var jobs []batchJob + for i := 0; i < len(allCalls); i += batchSize { + end := min(i+batchSize, len(allCalls)) + jobs = append(jobs, batchJob{offset: i, calls: allCalls[i:end]}) + } + + allResults := make([]json.RawMessage, len(allCalls)) + + err := runWorkerPool( + ctx, jobs, concurrency, + func(j batchJob) (indexedBatchResult, error) { + res, err := batchRPC(ctx, url, j.calls, defaultRetries) + return indexedBatchResult{offset: j.offset, results: res}, err + }, + func(ir indexedBatchResult) { + copy(allResults[ir.offset:ir.offset+len(ir.results)], ir.results) + }, + label, + ) + if err != nil { + return nil, err + } + + return allResults, nil +} diff --git a/tools/exit_certificate/rpc_test.go b/tools/exit_certificate/rpc_test.go new file mode 100644 index 000000000..3d16f3926 --- /dev/null +++ b/tools/exit_certificate/rpc_test.go @@ -0,0 +1,371 @@ +package exit_certificate + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestBatchRPC_Success(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var requests []jsonRPCRequest + err := json.NewDecoder(r.Body).Decode(&requests) + require.NoError(t, err) + require.Len(t, requests, 2) + + responses := []jsonRPCResponse{ + {JSONRPC: "2.0", ID: 1, Result: json.RawMessage(`"0x64"`)}, + {JSONRPC: "2.0", ID: 2, Result: json.RawMessage(`"0xc8"`)}, + } + w.Header().Set("Content-Type", "application/json") + require.NoError(t, json.NewEncoder(w).Encode(responses)) + })) + defer server.Close() + + ctx := context.Background() + calls := []RPCCall{ + {Method: "eth_blockNumber", Params: nil}, + {Method: "eth_blockNumber", Params: nil}, + } + + results, err := batchRPC(ctx, server.URL, calls, 1) + require.NoError(t, err) + require.Len(t, results, 2) + + var val1, val2 string + require.NoError(t, json.Unmarshal(results[0], &val1)) + require.NoError(t, json.Unmarshal(results[1], &val2)) + require.Equal(t, "0x64", val1) + require.Equal(t, "0xc8", val2) +} + +func TestBatchRPC_RPCError(t *testing.T) { + // Single-call batch where the node always returns a per-item RPC error. + // batchRPC exhausts retries and returns an error. + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + responses := []jsonRPCResponse{ + {JSONRPC: "2.0", ID: 1, Error: &jsonRPCError{Code: -32000, Message: "not found"}}, + } + w.Header().Set("Content-Type", "application/json") + require.NoError(t, json.NewEncoder(w).Encode(responses)) + })) + defer server.Close() + + ctx := context.Background() + calls := []RPCCall{ + {Method: "eth_getBlockByNumber", Params: []any{"0x1", false}}, + } + + _, err := batchRPC(ctx, server.URL, calls, 1) + require.Error(t, err) + require.Contains(t, err.Error(), "1/1 calls still failing") +} + +func TestBatchRPC_MultipleCallsOneError(t *testing.T) { + // Two-call batch where the second call always returns a per-item RPC error. + // batchRPC exhausts retries and returns an error — no nil slots are silently accepted. + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var requests []jsonRPCRequest + require.NoError(t, json.NewDecoder(r.Body).Decode(&requests)) + responses := make([]jsonRPCResponse, len(requests)) + for i, req := range requests { + if req.Method == "eth_getBlockByNumber" { + responses[i] = jsonRPCResponse{JSONRPC: "2.0", ID: req.ID, Error: &jsonRPCError{Code: -32000, Message: "not found"}} + } else { + responses[i] = jsonRPCResponse{JSONRPC: "2.0", ID: req.ID, Result: json.RawMessage(`"0x1"`)} + } + } + w.Header().Set("Content-Type", "application/json") + require.NoError(t, json.NewEncoder(w).Encode(responses)) + })) + defer server.Close() + + ctx := context.Background() + calls := []RPCCall{ + {Method: "eth_blockNumber", Params: nil}, + {Method: "eth_getBlockByNumber", Params: []any{"0x999", false}}, + } + + _, err := batchRPC(ctx, server.URL, calls, 1) + require.Error(t, err) + require.Contains(t, err.Error(), "1/2 calls still failing") +} + +func TestBatchRPC_RetriesFailedItems(t *testing.T) { + // Two-call batch: both fail on attempt 1, both succeed on attempt 2. + // batchRPC must retry only the failed items and return complete results with no error. + callCount := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var requests []jsonRPCRequest + require.NoError(t, json.NewDecoder(r.Body).Decode(&requests)) + callCount++ + responses := make([]jsonRPCResponse, len(requests)) + for i, req := range requests { + if callCount == 1 { + responses[i] = jsonRPCResponse{JSONRPC: "2.0", ID: req.ID, Error: &jsonRPCError{Code: -32000, Message: "overloaded"}} + } else { + responses[i] = jsonRPCResponse{JSONRPC: "2.0", ID: req.ID, Result: json.RawMessage(`"0x1"`)} + } + } + w.Header().Set("Content-Type", "application/json") + require.NoError(t, json.NewEncoder(w).Encode(responses)) + })) + defer server.Close() + + ctx := context.Background() + calls := []RPCCall{ + {Method: "eth_getBalance", Params: nil}, + {Method: "eth_getBalance", Params: nil}, + } + + results, err := batchRPC(ctx, server.URL, calls, 2) + require.NoError(t, err) + require.Len(t, results, 2) + require.NotNil(t, results[0]) + require.NotNil(t, results[1]) + require.Equal(t, 2, callCount) +} + +func TestBatchRPC_HTTPError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte("internal server error")) + })) + defer server.Close() + + ctx := context.Background() + calls := []RPCCall{ + {Method: "eth_blockNumber", Params: nil}, + } + + _, err := batchRPC(ctx, server.URL, calls, 1) + require.Error(t, err) + require.Contains(t, err.Error(), "500") +} + +func TestBatchRPC_ContextCancelled(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(5 * time.Second) + })) + defer server.Close() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + calls := []RPCCall{ + {Method: "eth_blockNumber", Params: nil}, + } + + _, err := batchRPC(ctx, server.URL, calls, 1) + require.Error(t, err) +} + +func TestSingleRPC_Success(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := jsonRPCResponse{ + JSONRPC: "2.0", + ID: 1, + Result: json.RawMessage(`"0x100"`), + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + ctx := context.Background() + result, err := singleRPC(ctx, server.URL, "eth_blockNumber", nil, 1) + require.NoError(t, err) + + var val string + require.NoError(t, json.Unmarshal(result, &val)) + require.Equal(t, "0x100", val) +} + +func TestSingleRPC_RPCError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := jsonRPCResponse{ + JSONRPC: "2.0", + ID: 1, + Error: &jsonRPCError{Code: -32600, Message: "invalid request"}, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + ctx := context.Background() + _, err := singleRPC(ctx, server.URL, "eth_blockNumber", nil, 1) + require.Error(t, err) + require.Contains(t, err.Error(), "invalid request") +} + +func TestSleepWithBackoff(t *testing.T) { + require.NotPanics(t, func() { sleepWithBackoff(context.Background(), 0) }) +} + +func TestSleepWithBackoff_ContextCancelled(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithCancel(context.Background()) + cancel() // already cancelled before the call + + start := time.Now() + sleepWithBackoff(ctx, 1) // attempt 1 → 2000 ms without context awareness + require.Less(t, time.Since(start), 100*time.Millisecond, "sleepWithBackoff must return immediately when context is cancelled") +} + +func TestSingleRPCAuth_SendsBearerToken(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "Bearer my-iap-token", r.Header.Get("Authorization")) + resp := jsonRPCResponse{JSONRPC: "2.0", ID: 1, Result: json.RawMessage(`"ok"`)} + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + ctx := context.Background() + result, err := singleRPCAuth(ctx, server.URL, "test_method", nil, 1, "my-iap-token") + require.NoError(t, err) + var val string + require.NoError(t, json.Unmarshal(result, &val)) + require.Equal(t, "ok", val) +} + +func TestSingleRPCAuth_NoTokenSendsNoHeader(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Empty(t, r.Header.Get("Authorization")) + resp := jsonRPCResponse{JSONRPC: "2.0", ID: 1, Result: json.RawMessage(`"ok"`)} + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + ctx := context.Background() + _, err := singleRPCAuth(ctx, server.URL, "test_method", nil, 1, "") + require.NoError(t, err) +} + +func TestHttpGetJSON_Success(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "application/json", r.Header.Get("Accept")) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"key":"value"}`)) + })) + defer server.Close() + + ctx := context.Background() + body, err := httpGetJSON(ctx, server.URL) + require.NoError(t, err) + var result map[string]string + require.NoError(t, json.Unmarshal(body, &result)) + require.Equal(t, "value", result["key"]) +} + +func TestHttpGetJSON_HTTPError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte("not found")) + })) + defer server.Close() + + ctx := context.Background() + _, err := httpGetJSON(ctx, server.URL) + require.Error(t, err) + require.Contains(t, err.Error(), "404") +} + +func TestMaskRPCURL(t *testing.T) { + require.Equal(t, "https://node.example.com", maskRPCURL("https://node.example.com/api/v1?key=secret")) + require.Equal(t, "http://localhost:8545", maskRPCURL("http://localhost:8545/")) + require.Equal(t, "bad url", maskRPCURL("bad url")) +} + +func TestRPCExecutionError_WithData(t *testing.T) { + e := &RPCExecutionError{Code: -32000, Message: "execution reverted", Data: "0xdeadbeef"} + require.Contains(t, e.Error(), "execution reverted") + require.Contains(t, e.Error(), "0xdeadbeef") +} + +func TestRPCExecutionError_WithoutData(t *testing.T) { + e := &RPCExecutionError{Code: -32000, Message: "execution reverted"} + require.Equal(t, "RPC error: execution reverted", e.Error()) +} + +func TestConcurrentBatchRPC_Basic(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var requests []jsonRPCRequest + require.NoError(t, json.NewDecoder(r.Body).Decode(&requests)) + responses := make([]jsonRPCResponse, len(requests)) + for i, req := range requests { + responses[i] = jsonRPCResponse{JSONRPC: "2.0", ID: req.ID, Result: json.RawMessage(`"0x1"`)} + } + w.Header().Set("Content-Type", "application/json") + require.NoError(t, json.NewEncoder(w).Encode(responses)) + })) + defer server.Close() + + ctx := context.Background() + calls := make([]RPCCall, 5) + for i := range calls { + calls[i] = RPCCall{Method: "eth_blockNumber", Params: nil} + } + + results, err := concurrentBatchRPC(ctx, server.URL, calls, 2, 2, "test") + require.NoError(t, err) + require.Len(t, results, 5) + for _, r := range results { + require.NotNil(t, r) + } +} + +func TestDoRPCWithRetry_ExhaustsRetries(t *testing.T) { + attempts := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + attempts++ + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + ctx := context.Background() + body, _ := json.Marshal(jsonRPCRequest{JSONRPC: "2.0", Method: "eth_blockNumber", ID: 1}) + _, err := doRPCWithRetry(ctx, server.URL, body, 2, "") + require.Error(t, err) + require.Contains(t, err.Error(), "RPC failed after 2 attempts") + require.Equal(t, 2, attempts) +} + +func TestConcurrentBatchRPC_Empty(t *testing.T) { + results, err := concurrentBatchRPC(context.Background(), "http://unused", nil, 10, 2, "test") + require.NoError(t, err) + require.Nil(t, results) +} + +func TestBatchRPC_SingleResponse(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := jsonRPCResponse{ + JSONRPC: "2.0", + ID: 1, + Result: json.RawMessage(`"0x42"`), + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + ctx := context.Background() + calls := []RPCCall{ + {Method: "eth_blockNumber", Params: nil}, + } + + results, err := batchRPC(ctx, server.URL, calls, 1) + require.NoError(t, err) + require.Len(t, results, 1) + + var val string + require.NoError(t, json.Unmarshal(results[0], &val)) + require.Equal(t, "0x42", val) +} diff --git a/tools/exit_certificate/run.go b/tools/exit_certificate/run.go new file mode 100644 index 000000000..947db832a --- /dev/null +++ b/tools/exit_certificate/run.go @@ -0,0 +1,1057 @@ +package exit_certificate + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + agglayertypes "github.com/agglayer/aggkit/agglayer/types" + "github.com/agglayer/aggkit/log" + "github.com/ethereum/go-ethereum/common" + "github.com/urfave/cli/v2" +) + +const ( + dirPermissions = 0o755 + filePermissions = 0o600 +) + +// Run is the CLI entry point. +func Run(c *cli.Context) error { + ctx := context.Background() + + logLevel := "info" + if c.Bool("verbose") { + logLevel = "debug" + } + log.Init(log.Config{ + Environment: log.EnvironmentDevelopment, + Level: logLevel, + Outputs: []string{"stderr"}, + }) + + cfg, err := LoadConfig(c.String("config")) + if err != nil { + return fmt.Errorf("load config: %w", err) + } + + if err := os.MkdirAll(cfg.Options.OutputDir, dirPermissions); err != nil { + return fmt.Errorf("create output dir: %w", err) + } + if err := migrateStepAToA1(cfg.Options.OutputDir); err != nil { + return err + } + + step := c.String("step") + if step == "" || step == "all" { + return runAll(ctx, cfg) + } + + steps, err := parseStepList(step) + if err != nil { + return err + } + for _, s := range steps { + if err := runSingleStep(ctx, s, cfg); err != nil { + return err + } + } + return nil +} + +// orderedSteps is the canonical pipeline order used for range expansion. +// "a" and "b" are aliases for their sub-steps and are handled in parseStepList; not listed here. +var orderedSteps = []string{ + "check", "0", "a1", "a2", "b1", "b2", "b3", "c", "d", "e", "f", "g1", "g2", "h", "i", "sign", "submit", "wait", +} + +// lastAutoStep is the implicit end for open ranges (X-). +// "submit" and "wait" must always be specified explicitly. +const lastAutoStep = "sign" + +// parseStepList splits a comma-separated step list, expanding range notation. +// "f-i" → ["f", "g", "h", "i"] +// "f-" → ["f", "g", "h", "i", "sign", "submit", "wait"] +// "h, i, sign" → ["h", "i", "sign"] +// "a" → ["a1", "a2"] (alias for both sub-steps) +// "b" → ["b1", "b2", "b3"] (alias for all three sub-steps) +// "g" → ["g1", "g2"] (alias for both sub-steps) +// "a-b" → ["a1", "a2", "b1", "b2", "b3"] ("a"→"a1" start, "b"→"b3" end) +// "0-a" → ["0", "a1", "a2"] ("a" expands to "a2" as range end) +func parseStepList(raw string) ([]string, error) { + var steps []string + for _, token := range strings.Split(raw, ",") { + token = strings.TrimSpace(token) + if token == "" { + continue + } + if strings.Contains(token, "-") { + // Map "a"/"b"/"g" to their sub-step boundaries before expanding ranges. + parts := strings.SplitN(token, "-", splitInTwo) + from, to := strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1]) + from = aliasRangeStart(from) + to = aliasRangeEnd(to) + expanded, err := expandStepRange(from + "-" + to) + if err != nil { + return nil, err + } + steps = append(steps, expanded...) + } else if sub, ok := stepAliases[token]; ok { + steps = append(steps, sub...) + } else { + steps = append(steps, token) + } + } + return steps, nil +} + +// stepAliases maps a step alias to the ordered sub-steps it expands to. +var stepAliases = map[string][]string{ + "a": {"a1", "a2"}, + "b": {"b1", "b2", "b3"}, + "g": {"g1", "g2"}, +} + +// aliasRangeStart maps an alias used as a range start to its first sub-step. +func aliasRangeStart(s string) string { + if sub, ok := stepAliases[s]; ok { + return sub[0] + } + return s +} + +// aliasRangeEnd maps an alias used as a range end to its last sub-step. +func aliasRangeEnd(s string) string { + if sub, ok := stepAliases[s]; ok { + return sub[len(sub)-1] + } + return s +} + +func expandStepRange(token string) ([]string, error) { + parts := strings.SplitN(token, "-", splitInTwo) + from, to := strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1]) + + fromIdx := -1 + for i, s := range orderedSteps { + if s == from { + fromIdx = i + break + } + } + if fromIdx == -1 { + return nil, fmt.Errorf("unknown step in range %q: %q", token, from) + } + + // Open range: stop at lastAutoStep (submit/wait require explicit opt-in). + toIdx := -1 + for i, s := range orderedSteps { + if s == lastAutoStep { + toIdx = i + break + } + } + // When the range starts at or after submit/wait (i.e. past lastAutoStep), the user has + // explicitly opted into those steps, so an open range extends to the last step instead. + if fromIdx > toIdx { + toIdx = len(orderedSteps) - 1 + } + if to != "" { + toIdx = -1 + for i, s := range orderedSteps { + if s == to { + toIdx = i + break + } + } + if toIdx == -1 { + return nil, fmt.Errorf("unknown step in range %q: %q", token, to) + } + if toIdx < fromIdx { + return nil, fmt.Errorf("invalid range %q: %q comes before %q in the pipeline", token, to, from) + } + } + + return orderedSteps[fromIdx : toIdx+1], nil +} + +func resolveLatestBlock(ctx context.Context, rpcURL string) (uint64, error) { + result, err := singleRPC(ctx, rpcURL, "eth_blockNumber", nil, defaultRetries) + if err != nil { + return 0, err + } + var hex string + if err := json.Unmarshal(result, &hex); err != nil { + return 0, fmt.Errorf("parse block number: %w", err) + } + return hexToUint64(hex), nil +} + +// --- Full pipeline --- + +// runAll executes: CHECK → 0 → A → B → C → D → E → F → G → H → I. +func runAll(ctx context.Context, cfg *Config) error { + dir := cfg.Options.OutputDir + if err := os.MkdirAll(dir, dirPermissions); err != nil { + return fmt.Errorf("create output dir: %w", err) + } + + startTime := time.Now() + logPipelineConfig(cfg) + + checkResult, err := RunStepCheck(ctx, cfg) + if err != nil { + return fmt.Errorf("step CHECK: %w", err) + } + saveJSON(dir, fileStepCheckResult, checkResult) + + lbtEntries, wrappedTokens, targetBlock, err := resolveOrGenerateLBT(ctx, cfg, dir) + if err != nil { + return fmt.Errorf("step 0 (LBT): %w", err) + } + + stepAResult, err := runAllStepA(ctx, cfg, dir, targetBlock, wrappedTokens) + if err != nil { + return err + } + + stepBResult, err := runAllStepB(ctx, cfg, dir, targetBlock, stepAResult) + if err != nil { + return err + } + + stepCResult, err := runAllStepC(dir, lbtEntries, stepBResult) + if err != nil { + return err + } + + stepDResult, err := runAllStepD(cfg, dir, stepBResult, stepCResult) + if err != nil { + return err + } + + finalCertificate, err := runAllStepE(ctx, cfg, dir, stepDResult.Certificate) + if err != nil { + return err + } + + finalCertificate, err = runAllStepF(ctx, cfg, dir, lbtEntries, stepDResult.Certificate, finalCertificate) + if err != nil { + return err + } + + gResult, err := runAllStepG(ctx, cfg, dir, targetBlock, finalCertificate, lbtEntries) + if err != nil { + return err + } + + hResult, err := runAllStepH(ctx, cfg, dir, gResult) + if err != nil { + return err + } + + if err := runAllStepI(ctx, cfg, dir, finalCertificate, gResult, hResult); err != nil { + return err + } + + if cfg.SignerConfig.Method != "" { + signedCert, err := RunStepSign(ctx, cfg, finalCertificate) + if err != nil { + return fmt.Errorf("step SIGN: %w", err) + } + saveJSON(dir, fileSignedCertificate, signedCert) + } + + log.Info("") + log.Info("╔═══════════════════════════════════════════╗") + log.Info("║ Pipeline Complete ║") + log.Info("╚═══════════════════════════════════════════╝") + log.Infof("Total bridge exits: %d", len(finalCertificate.BridgeExits)) + log.Infof("Elapsed time: %.1fs", time.Since(startTime).Seconds()) + log.Infof("Output directory: %s", dir) + + return nil +} + +func runAllStepA( + ctx context.Context, cfg *Config, dir string, targetBlock uint64, wrappedTokens []WrappedToken, +) (*StepAResult, error) { + a1Result, err := RunStepA1(ctx, cfg, targetBlock) + if err != nil { + return nil, fmt.Errorf("step A1: %w", err) + } + saveJSON(dir, fileStepA1Addresses, a1Result.Addresses) + saveJSON(dir, fileStepA1FailedTrace, a1Result.FailedTraces) + + a2Result, err := RunStepA2(ctx, cfg, a1Result.FailedTraces) + if err != nil { + return nil, fmt.Errorf("step A2: %w", err) + } + saveJSON(dir, fileStepA2Addresses, a2Result.Addresses) + + combined := mergeAddresses(a1Result.Addresses, a2Result.Addresses) + log.Infof("STEP A complete: %d addresses (A1: %d, A2 new: %d)", + len(combined), len(a1Result.Addresses), len(combined)-len(a1Result.Addresses)) + saveJSON(dir, fileStepAAddresses, combined) + + result := &StepAResult{ + Addresses: combined, + FailedTraces: a1Result.FailedTraces, + WrappedTokens: wrappedTokens, + } + if len(wrappedTokens) > 0 { + log.Infof("Using %d wrapped tokens for balance scanning", len(wrappedTokens)) + } + return result, nil +} + +func runAllStepB( + ctx context.Context, cfg *Config, dir string, targetBlock uint64, stepAResult *StepAResult, +) (*StepBResult, error) { + stepBResult, err := RunStepB(ctx, cfg, targetBlock, stepAResult) + if err != nil { + return nil, fmt.Errorf("step B: %w", err) + } + saveJSON(dir, fileStepBEOABalances, stepBResult.EOABalances) + saveJSON(dir, fileStepBAccumulated, stepBResult.Accumulated) + saveJSON(dir, fileStepBContractAddresses, stepBResult.ContractAddresses) + saveJSON(dir, fileStepB2DetectedERC20s, stepBResult.DetectedERC20s) + saveJSON(dir, fileStepB2DiscardedERC20s, stepBResult.DiscardedERC20s) + saveJSON(dir, fileStepB3ERC20Holders, stepBResult.ERC20HolderBreakdowns) + return stepBResult, nil +} + +func runAllStepC(dir string, lbtEntries []LBTEntry, stepBResult *StepBResult) (*StepCResult, error) { + if len(lbtEntries) == 0 { + log.Warn("STEP C skipped: no LBT data available") + return &StepCResult{}, nil + } + stepCResult, err := RunStepC(lbtEntries, stepBResult) + if err != nil { + return nil, fmt.Errorf("step C: %w", err) + } + saveJSON(dir, fileStepCSCLockedValues, stepCResult.SCLockedValues) + saveJSON(dir, fileStepCHolderBridges, stepCResult.HolderBridges) + return stepCResult, nil +} + +func runAllStepF( + ctx context.Context, cfg *Config, dir string, + lbtEntries []LBTEntry, + stepDCert *agglayertypes.Certificate, + finalCert *agglayertypes.Certificate, +) (*agglayertypes.Certificate, error) { + // RunStepF itself honours useAgglayerAdminToStepFCheck: when false it runs the offline LBT vs + // certificate comparison instead of the agglayer admin query. + result, err := RunStepF(ctx, cfg, stepDCert, lbtEntries) + if err != nil { + return nil, fmt.Errorf("step F: %w", err) + } + if result.TokenBalances != nil { + saveJSON(dir, fileStepFTokenBalances, result.TokenBalances) + } + saveJSON(dir, fileStepFChecks, result.Checks) + if result.CappedCertificate != nil { + // Apply the same per-token caps to the final certificate (which may include step E exits). + cappedFinal := *finalCert + cappedFinal.BridgeExits = capCertificateExits(finalCert.BridgeExits, result.Checks) + saveJSON(dir, fileStepFCappedCertificate, &cappedFinal) + log.Infof("🔧 Capped final certificate saved (%d → %d bridge exits)", + len(finalCert.BridgeExits), len(cappedFinal.BridgeExits)) + return &cappedFinal, nil + } + return finalCert, nil +} + +func runAllStepG( + ctx context.Context, cfg *Config, dir string, targetBlock uint64, + certificate *agglayertypes.Certificate, lbtEntries []LBTEntry, +) (*StepGResult, error) { + g1Result, err := RunStepG1(ctx, cfg, targetBlock) + if err != nil { + return nil, fmt.Errorf("step G1: %w", err) + } + saveJSON(dir, fileStepG1ShadowForkBlock, g1Result) + + result, err := RunStepG2(ctx, cfg, g1Result.ShadowForkBlock, certificate, lbtEntries) + if err != nil { + return nil, fmt.Errorf("step G2: %w", err) + } + saveJSON(dir, fileStepGNewLocalExitRoot, result) + // RunStepG2 reorders certificate.BridgeExits to the shadow-fork deposit order; persist the + // reordered certificate for inspection and parity with single-step mode. + saveJSON(dir, fileStepGReorderedCertificate, certificate) + return result, nil +} + +func runAllStepH(ctx context.Context, cfg *Config, dir string, gResult *StepGResult) (*StepHResult, error) { + result, err := RunStepH(ctx, cfg, gResult) + if err != nil { + return nil, fmt.Errorf("step H: %w", err) + } + saveJSON(dir, fileStepHPreviousLocalExitRoot, result) + return result, nil +} + +func runAllStepI( + ctx context.Context, cfg *Config, dir string, + certificate *agglayertypes.Certificate, gResult *StepGResult, hResult *StepHResult, +) error { + if err := RunStepI(ctx, cfg, certificate, gResult, hResult); err != nil { + return fmt.Errorf("step I: %w", err) + } + saveJSON(dir, fileFinalCertificate, certificate) + return nil +} + +func runAllStepD(cfg *Config, dir string, stepBResult *StepBResult, stepCResult *StepCResult) (*StepDResult, error) { + stepDResult, err := RunStepD(cfg, stepBResult, stepCResult) + if err != nil { + return nil, fmt.Errorf("step D: %w", err) + } + saveJSON(dir, fileStepDCertificate, stepDResult.Certificate) + return stepDResult, nil +} + +// saveStepEFiles persists step E outputs to disk. Always writes the unclaimed bridges and +// messages files; only writes the certificate when it is non-nil. +func saveStepEFiles(dir string, result *StepEResult) { + if result == nil { + return + } + saveJSON(dir, fileStepEUnclaimedBridges, result.UnclaimedBridges) + saveJSON(dir, fileStepEUnclaimedMsgs, result.UnclaimedMessages) + if result.FinalCertificate != nil { + saveJSON(dir, fileStepECertificate, result.FinalCertificate) + } +} + +func runAllStepE( + ctx context.Context, cfg *Config, dir string, stepDCert *agglayertypes.Certificate, +) (*agglayertypes.Certificate, error) { + if cfg.L1RPCURL == "" { + log.Warn("STEP E skipped: no L1 RPC provided") + return stepDCert, nil + } + result, err := RunStepE(ctx, cfg, stepDCert) + saveStepEFiles(dir, result) + if err != nil { + return nil, fmt.Errorf("step E: %w", err) + } + return result.FinalCertificate, nil +} + +func logPipelineConfig(cfg *Config) { + log.Info("╔═══════════════════════════════════════════╗") + log.Info("║ Exit Certificate Tool — Full Pipeline ║") + log.Info("╚═══════════════════════════════════════════╝") + log.Infof("L2 RPC: %s", cfg.L2RPCURL) + if cfg.L1RPCURL != "" { + log.Infof("L1 RPC: %s", cfg.L1RPCURL) + } else { + log.Info("L1 RPC: (not configured — step E will be skipped)") + } + log.Infof("L2 Bridge: %s", cfg.L2BridgeAddress.Hex()) + log.Infof("Target Block: %s", cfg.TargetBlock.String()) + log.Infof("L2 Network ID: %d", cfg.L2NetworkID) + log.Infof("Exit Address: %s", cfg.ExitAddress.Hex()) + log.Infof("Dest Network: %d", cfg.DestinationNetwork) + log.Infof("Output Dir: %s", cfg.Options.OutputDir) + log.Infof("Concurrency: %d", cfg.Options.ConcurrencyLimit) + log.Infof("Block Range: %d", cfg.Options.BlockRange) + log.Infof("RPC Batch Size: %d", cfg.Options.RPCBatchSize) + log.Infof("L2 Start Block: %d", cfg.Options.L2StartBlock) + if cfg.Options.AgglayerClient.GRPC != nil && cfg.Options.AgglayerClient.GRPC.URL != "" { + log.Infof("Agglayer gRPC: %s", cfg.Options.AgglayerClient.GRPC.URL) + } else { + log.Info("Agglayer gRPC: (not configured — step submit will fail)") + } + if cfg.SignerConfig.Method != "" { + log.Infof("Signer: method=%s", cfg.SignerConfig.Method) + } else { + log.Info("Signer: (not configured — certificate will not be signed)") + } +} + +// --- Single step --- + +func runSingleStep(ctx context.Context, step string, cfg *Config) error { + dir := cfg.Options.OutputDir + if err := os.MkdirAll(dir, dirPermissions); err != nil { + return fmt.Errorf("create output dir: %w", err) + } + + switch step { + case "check": + return runSingleCheck(ctx, cfg, dir) + case "0": + return runSingle0(ctx, cfg, dir) + case "a": + return runSingleA(ctx, cfg, dir) + case "a1": + return runSingleA1(ctx, cfg, dir) + case "a2": + return runSingleA2(ctx, cfg, dir) + case "b": + return runSingleB(ctx, cfg, dir) + case "b1": + return runSingleB1(ctx, cfg, dir) + case "b2": + return runSingleB2(ctx, cfg, dir) + case "b3": + return runSingleB3(ctx, cfg, dir) + case "c": + return runSingleC(dir) + case "d": + return runSingleD(cfg, dir) + case "e": + return runSingleE(ctx, cfg, dir) + case "f": + return runSingleF(ctx, cfg, dir) + case "g": + return runSingleG(ctx, cfg, dir) + case "g1": + return runSingleG1(ctx, cfg, dir) + case "g2": + return runSingleG2(ctx, cfg, dir) + case "h": + return runSingleH(ctx, cfg, dir) + case "i": + return runSingleI(ctx, cfg, dir) + case "sign": + return runSingleSign(ctx, cfg, dir) + case "submit": + return runSingleSubmit(ctx, cfg, dir) + case "wait": + return runSingleWait(ctx, cfg, dir) + default: + return fmt.Errorf( + "unknown step: %s (use check, 0, a, a1, a2, b, b1, b2, b3, c, d, e, f, g, g1, g2, h, i, sign, submit, wait, or all)", + step, + ) + } +} + +func runSingleCheck(ctx context.Context, cfg *Config, dir string) error { + result, err := RunStepCheck(ctx, cfg) + if err != nil { + return err + } + saveJSON(dir, fileStepCheckResult, result) + return nil +} + +func runSingle0(ctx context.Context, cfg *Config, dir string) error { + result, err := RunStep0(ctx, cfg) + if err != nil { + return err + } + saveJSON(dir, fileStep0TargetBlock, result.TargetBlock) + saveJSON(dir, fileStep0LBT, result.Entries) + return nil +} + +// runSingleA runs A1 then A2, producing all four output files. +func runSingleA(ctx context.Context, cfg *Config, dir string) error { + if err := runSingleA1(ctx, cfg, dir); err != nil { + return err + } + return runSingleA2(ctx, cfg, dir) +} + +// runSingleA1 runs Step A1 and writes step-a1-addresses.json and step-a1-failed-traces.json. +func runSingleA1(ctx context.Context, cfg *Config, dir string) error { + targetBlock, err := loadTargetBlock(dir) + if err != nil { + return err + } + result, err := RunStepA1(ctx, cfg, targetBlock) + if err != nil { + return err + } + saveJSON(dir, fileStepA1Addresses, result.Addresses) + saveJSON(dir, fileStepA1FailedTrace, result.FailedTraces) + return nil +} + +// runSingleA2 runs Step A2 and writes step-a2-addresses.json and step-a-addresses.json. +// Legacy step-a-* files are migrated to step-a1-* at startup (see Run), so they will +// already be in the correct location by the time this function is called. +func runSingleA2(ctx context.Context, cfg *Config, dir string) error { + var failedTraces []FailedTrace + if err := loadJSON(dir, fileStepA1FailedTrace, &failedTraces); err != nil { + return fmt.Errorf("load step A1 failed traces (run step a1 first): %w", err) + } + + a2Result, err := RunStepA2(ctx, cfg, failedTraces) + if err != nil { + return err + } + saveJSON(dir, fileStepA2Addresses, a2Result.Addresses) + + var a1Addresses []common.Address + if err := loadJSON(dir, fileStepA1Addresses, &a1Addresses); err != nil { + return fmt.Errorf("load step A1 addresses: %w", err) + } + log.Debugf("STEP A2 merging %d A2 addresses with %d A1 addresses", len(a2Result.Addresses), len(a1Addresses)) + combined := mergeAddresses(a1Addresses, a2Result.Addresses) + log.Infof("STEP A complete: %d addresses (A1: %d, A2 new: %d)", + len(combined), len(a1Addresses), len(combined)-len(a1Addresses)) + saveJSON(dir, fileStepAAddresses, combined) + return nil +} + +// migrateStepAToA1 renames legacy step-a-* output files to step-a1-* when the A1 files +// are absent. This allows step A2 to be run after a pipeline that predates the A1/A2 split. +func migrateStepAToA1(dir string) error { + rename := func(oldName, newName string) error { + oldPath := filepath.Join(dir, oldName) + newPath := filepath.Join(dir, newName) + if _, err := os.Stat(newPath); err == nil { + return nil // new file already exists — nothing to do + } + if _, err := os.Stat(oldPath); err != nil { + return nil // old file also absent — nothing to do + } + log.Infof("Migrating %s → %s", oldName, newName) + if err := os.Rename(oldPath, newPath); err != nil { + return fmt.Errorf("rename %s: %w", oldName, err) + } + return nil + } + if err := rename(fileStepAAddresses, fileStepA1Addresses); err != nil { + return err + } + return rename(fileStepAFailedTraces, fileStepA1FailedTrace) +} + +// runSingleB runs B1 then B2 then B3, producing all step-b* output files. +func runSingleB(ctx context.Context, cfg *Config, dir string) error { + if err := runSingleB1(ctx, cfg, dir); err != nil { + return err + } + if err := runSingleB2(ctx, cfg, dir); err != nil { + return err + } + return runSingleB3(ctx, cfg, dir) +} + +// runSingleB1 runs Step B1 and writes step-b-eoa-balances.json, +// step-b-accumulated.json, and step-b-contract-addresses.json. +func runSingleB1(ctx context.Context, cfg *Config, dir string) error { + var addresses []common.Address + if err := loadJSON(dir, fileStepAAddresses, &addresses); err != nil { + return fmt.Errorf("load step A output: %w", err) + } + wrappedTokens, err := loadWrappedTokensFromLBT(dir) + if err != nil { + return err + } + log.Infof("Using %d wrapped tokens for balance scanning", len(wrappedTokens)) + + targetBlock, err := loadTargetBlock(dir) + if err != nil { + return err + } + result, err := RunStepB1(ctx, cfg, targetBlock, &StepAResult{ + Addresses: addresses, + WrappedTokens: wrappedTokens, + }) + if err != nil { + return err + } + saveJSON(dir, fileStepBEOABalances, result.EOABalances) + saveJSON(dir, fileStepBAccumulated, result.Accumulated) + saveJSON(dir, fileStepBContractAddresses, result.ContractAddresses) + return nil +} + +// runSingleB2 runs Step B2 and writes step-b2-detected-erc20s.json and +// step-b2-discarded-erc20s.json. +// Requires step-b-contract-addresses.json from B1, step-a-addresses.json, and step-0-lbt.json. +func runSingleB2(ctx context.Context, cfg *Config, dir string) error { + var contractAddrs []common.Address + if err := loadJSON(dir, fileStepBContractAddresses, &contractAddrs); err != nil { + return fmt.Errorf("load step B1 contract addresses (run step b1 first): %w", err) + } + var allAddresses []common.Address + if err := loadJSON(dir, fileStepAAddresses, &allAddresses); err != nil { + return fmt.Errorf("load step A addresses: %w", err) + } + eoaAddrs := filterEOAs(allAddresses, contractAddrs) + + wrappedTokens, err := loadWrappedTokensFromLBT(dir) + if err != nil { + return err + } + + targetBlock, err := loadTargetBlock(dir) + if err != nil { + return err + } + result, err := RunStepB2(ctx, cfg, targetBlock, contractAddrs, eoaAddrs, wrappedTokens) + if err != nil { + return err + } + saveJSON(dir, fileStepB2DetectedERC20s, result.DetectedERC20s) + saveJSON(dir, fileStepB2DiscardedERC20s, result.DiscardedERC20s) + return nil +} + +// runSingleB3 runs Step B3 and writes step-b3-erc20-holders.json. +// Requires step-b2-detected-erc20s.json from B2, step-b-contract-addresses.json from B1, +// step-a-addresses.json, and step-0-l2_target_block.json. +func runSingleB3(ctx context.Context, cfg *Config, dir string) error { + var contractAddrs []common.Address + if err := loadJSON(dir, fileStepBContractAddresses, &contractAddrs); err != nil { + return fmt.Errorf("load step B1 contract addresses (run step b1 first): %w", err) + } + var allAddresses []common.Address + if err := loadJSON(dir, fileStepAAddresses, &allAddresses); err != nil { + return fmt.Errorf("load step A addresses: %w", err) + } + eoaAddrs := filterEOAs(allAddresses, contractAddrs) + + var detectedERC20s []DetectedERC20 + if err := loadJSON(dir, fileStepB2DetectedERC20s, &detectedERC20s); err != nil { + return fmt.Errorf("load step B2 detected ERC-20s (run step b2 first): %w", err) + } + b2Result := &StepB2Result{DetectedERC20s: detectedERC20s} + + targetBlock, err := loadTargetBlock(dir) + if err != nil { + return err + } + result, err := RunStepB3(ctx, cfg, targetBlock, eoaAddrs, b2Result) + if err != nil { + return err + } + saveJSON(dir, fileStepB3ERC20Holders, result.Breakdowns) + return nil +} + +func runSingleC(dir string) error { + var accumulated []AccumulatedBalance + if err := loadJSON(dir, fileStepBAccumulated, &accumulated); err != nil { + return fmt.Errorf("load step B output: %w", err) + } + var lbtEntries []LBTEntry + if err := loadJSON(dir, fileStep0LBT, &lbtEntries); err != nil { + return fmt.Errorf("load LBT data (step 0): %w", err) + } + // Load holder breakdowns from B3 if available; absence is not an error. + var breakdowns []ERC20HolderBreakdown + _ = loadJSON(dir, fileStepB3ERC20Holders, &breakdowns) + + result, err := RunStepC(lbtEntries, &StepBResult{ + Accumulated: accumulated, + ERC20HolderBreakdowns: breakdowns, + }) + if err != nil { + return err + } + saveJSON(dir, fileStepCSCLockedValues, result.SCLockedValues) + saveJSON(dir, fileStepCHolderBridges, result.HolderBridges) + return nil +} + +func runSingleD(cfg *Config, dir string) error { + var eoaBalances []EOABalance + if err := loadJSON(dir, fileStepBEOABalances, &eoaBalances); err != nil { + return fmt.Errorf("load step B output: %w", err) + } + var scLockedValues []SCLockedValue + if err := loadJSON(dir, fileStepCSCLockedValues, &scLockedValues); err != nil { + return fmt.Errorf("load step C output: %w", err) + } + var holderBridges []HolderBridge + _ = loadJSON(dir, fileStepCHolderBridges, &holderBridges) + + result, err := RunStepD(cfg, &StepBResult{EOABalances: eoaBalances}, &StepCResult{ + SCLockedValues: scLockedValues, + HolderBridges: holderBridges, + }) + if err != nil { + return err + } + saveJSON(dir, fileStepDCertificate, result.Certificate) + return nil +} + +func runSingleE(ctx context.Context, cfg *Config, dir string) error { + if cfg.L1RPCURL == "" { + return fmt.Errorf("step E requires l1RpcUrl in parameters") + } + var cert certificateJSON + if err := loadJSON(dir, fileStepDCertificate, &cert); err != nil { + return fmt.Errorf("load step D output: %w", err) + } + result, err := RunStepE(ctx, cfg, cert.toAgglayerCertificate()) + saveStepEFiles(dir, result) + return err +} + +func runSingleSign(ctx context.Context, cfg *Config, dir string) error { + var cert agglayertypes.Certificate + if err := loadJSON(dir, fileFinalCertificate, &cert); err != nil { + return fmt.Errorf("load final certificate: %w", err) + } + signed, err := RunStepSign(ctx, cfg, &cert) + if err != nil { + return err + } + saveJSON(dir, fileSignedCertificate, signed) + return nil +} + +func runSingleSubmit(ctx context.Context, cfg *Config, dir string) error { + var cert agglayertypes.Certificate + if err := loadJSON(dir, fileSignedCertificate, &cert); err != nil { + return fmt.Errorf("load signed certificate: %w", err) + } + result, err := RunStepSubmit(ctx, cfg, &cert) + if err != nil { + return err + } + saveJSON(dir, fileStepSubmitResult, result) + return nil +} + +func runSingleWait(ctx context.Context, cfg *Config, dir string) error { + var submitResult StepSubmitResult + if err := loadJSON(dir, fileStepSubmitResult, &submitResult); err != nil { + return fmt.Errorf("load step submit result: %w", err) + } + result, err := RunStepWait(ctx, cfg, &submitResult) + if err != nil { + return err + } + saveJSON(dir, fileStepWaitResult, result) + return nil +} + +func runSingleF(ctx context.Context, cfg *Config, dir string) error { + var cert certificateJSON + if err := loadJSON(dir, fileStepDCertificate, &cert); err != nil { + return fmt.Errorf("load step D certificate: %w", err) + } + + // Load LBT entries: used for the three-way comparison (agglayer mode) or the offline LBT vs + // certificate comparison (useAgglayerAdminToStepFCheck=false). nil disables the LBT check. + var lbtEntries []LBTEntry + lbtPath := filepath.Join(dir, fileStep0LBT) + if entries, err := LoadLBTEntries(lbtPath); err == nil { + lbtEntries = entries + } else { + log.Warnf("STEP F: LBT data not available, falling back to two-way comparison: %v", err) + } + + result, err := RunStepF(ctx, cfg, cert.toAgglayerCertificate(), lbtEntries) + if err != nil { + return err + } + if result.TokenBalances != nil { + saveJSON(dir, fileStepFTokenBalances, result.TokenBalances) + } + saveJSON(dir, fileStepFChecks, result.Checks) + if result.CappedCertificate != nil { + saveJSON(dir, fileStepFCappedCertificate, result.CappedCertificate) + } + return nil +} + +// fileExists reports whether path exists and is accessible. +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +// runSingleG runs G1 then G2, producing the step-g1 and step-g output files. +func runSingleG(ctx context.Context, cfg *Config, dir string) error { + if err := runSingleG1(ctx, cfg, dir); err != nil { + return err + } + return runSingleG2(ctx, cfg, dir) +} + +// runSingleG1 runs Step G1 and writes step-g1-shadow-fork-block.json (the block Step G2 forks at). +func runSingleG1(ctx context.Context, cfg *Config, dir string) error { + targetBlock, err := loadTargetBlock(dir) + if err != nil { + return err + } + result, err := RunStepG1(ctx, cfg, targetBlock) + if err != nil { + return err + } + saveJSON(dir, fileStepG1ShadowForkBlock, result) + return nil +} + +// runSingleG2 runs Step G2: it loads the shadow-fork block from G1, the certificate (capped from F +// or from E), and the LBT entries, then writes step-g-new-local-exit-root.json and the reordered +// step-g-reordered-certificate.json. +func runSingleG2(ctx context.Context, cfg *Config, dir string) error { + var g1Result StepG1Result + if err := loadJSON(dir, fileStepG1ShadowForkBlock, &g1Result); err != nil { + return fmt.Errorf("load step G1 result (run step g1 first): %w", err) + } + + var cert certificateJSON + cappedPath := filepath.Join(dir, fileStepFCappedCertificate) + if _, err := os.Stat(cappedPath); err == nil { + if err := loadJSON(dir, fileStepFCappedCertificate, &cert); err != nil { + return fmt.Errorf("load step F capped certificate: %w", err) + } + log.Warn("⚠️ Using capped certificate from step F (step-f-capped-certificate.json)") + } else { + if err := loadJSON(dir, fileStepECertificate, &cert); err != nil { + return fmt.Errorf("load step E certificate: %w", err) + } + log.Info("Using certificate from step E (step-e-exit-certificate.json)") + } + + lbtPath := filepath.Join(dir, fileStep0LBT) + var lbtEntries []LBTEntry + if entries, err := LoadLBTEntries(lbtPath); err == nil { + lbtEntries = entries + log.Infof("STEP G2: loaded %d LBT entries for token resolution", len(lbtEntries)) + } else { + log.Warnf("STEP G2: LBT not available, falling back to getTokenWrappedAddress: %v", err) + } + + aggCert := cert.toAgglayerCertificate() + result, err := RunStepG2(ctx, cfg, g1Result.ShadowForkBlock, aggCert, lbtEntries) + if err != nil { + return err + } + saveJSON(dir, fileStepGNewLocalExitRoot, result) + // RunStepG2 reorders aggCert.BridgeExits to the shadow-fork deposit order. Persist it so the + // single-step Step I picks up the reordered exits instead of the pre-G ordering. + saveJSON(dir, fileStepGReorderedCertificate, aggCert) + return nil +} + +func runSingleH(ctx context.Context, cfg *Config, dir string) error { + var gResult StepGResult + if err := loadJSON(dir, fileStepGNewLocalExitRoot, &gResult); err != nil { + return fmt.Errorf("load step G result: %w", err) + } + result, err := RunStepH(ctx, cfg, &gResult) + if err != nil { + return err + } + saveJSON(dir, fileStepHPreviousLocalExitRoot, result) + return nil +} + +func runSingleI(ctx context.Context, cfg *Config, dir string) error { + // Step I always builds on the Step G reordered certificate: Step G2 reorders the bridge exits + // to the shadow-fork deposit order (the authoritative ordering that matches the computed + // NewLocalExitRoot) and always writes step-g-reordered-certificate.json. Run Step G first. + var cert certificateJSON + if err := loadJSON(dir, fileStepGReorderedCertificate, &cert); err != nil { + return fmt.Errorf("load step G reordered certificate (run step g first): %w", err) + } + log.Info("Using reordered certificate from step G (step-g-reordered-certificate.json)") + var gResult StepGResult + if err := loadJSON(dir, fileStepGNewLocalExitRoot, &gResult); err != nil { + return fmt.Errorf("load step G result: %w", err) + } + var hResult StepHResult + if err := loadJSON(dir, fileStepHPreviousLocalExitRoot, &hResult); err != nil { + return fmt.Errorf("load step H result: %w", err) + } + aggCert := cert.toAgglayerCertificate() + if err := RunStepI(ctx, cfg, aggCert, &gResult, &hResult); err != nil { + return err + } + saveJSON(dir, fileFinalCertificate, aggCert) + return nil +} + +// --- LBT resolution --- + +// resolveOrGenerateLBT always runs Step 0 and saves step-0-lbt.json. +func resolveOrGenerateLBT(ctx context.Context, cfg *Config, dir string) ([]LBTEntry, []WrappedToken, uint64, error) { + result, err := RunStep0(ctx, cfg) + if err != nil { + return nil, nil, 0, err + } + saveJSON(dir, fileStep0TargetBlock, result.TargetBlock) + saveJSON(dir, fileStep0LBT, result.Entries) + return result.Entries, LBTEntriesToWrappedTokens(result.Entries), result.TargetBlock, nil +} + +// loadTargetBlock reads the resolved L2 target block number saved by Step 0. +func loadTargetBlock(dir string) (uint64, error) { + var n uint64 + if err := loadJSON(dir, fileStep0TargetBlock, &n); err != nil { + return 0, fmt.Errorf("load target block (run step 0 first): %w", err) + } + return n, nil +} + +// loadWrappedTokensFromLBT loads tokens from the step-0 output. +func loadWrappedTokensFromLBT(dir string) ([]WrappedToken, error) { + tokens, err := LoadLBTWrappedTokens(filepath.Join(dir, fileStep0LBT)) + if err != nil { + return nil, fmt.Errorf("no LBT data available: run step 0 first") + } + return tokens, nil +} + +// --- JSON I/O --- + +func saveJSON(dir, filename string, data any) { + path := filepath.Join(dir, filename) + content, err := json.MarshalIndent(data, "", " ") + if err != nil { + log.Errorf("Failed to marshal %s: %v", filename, err) + return + } + if err := os.WriteFile(path, content, filePermissions); err != nil { + log.Errorf("Failed to write %s: %v", path, err) + return + } + log.Infof("Written: %s", path) +} + +func loadJSON(dir, filename string, target any) error { + path := filepath.Join(dir, filename) + data, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("read %s: %w", path, err) + } + return json.Unmarshal(data, target) +} + +// certificateJSON supports loading a Certificate from the step-d output file. +type certificateJSON struct { + NetworkID uint32 `json:"network_id"` + Height uint64 `json:"height"` + PrevLocalExitRoot common.Hash `json:"prev_local_exit_root"` + NewLocalExitRoot common.Hash `json:"new_local_exit_root"` + BridgeExits json.RawMessage `json:"bridge_exits"` + ImportedBridges json.RawMessage `json:"imported_bridge_exits"` +} + +func (c *certificateJSON) toAgglayerCertificate() *agglayertypes.Certificate { + cert := &agglayertypes.Certificate{ + NetworkID: c.NetworkID, + Height: c.Height, + PrevLocalExitRoot: c.PrevLocalExitRoot, + NewLocalExitRoot: c.NewLocalExitRoot, + } + if len(c.BridgeExits) > 0 { + _ = json.Unmarshal(c.BridgeExits, &cert.BridgeExits) + } + if len(c.ImportedBridges) > 0 { + _ = json.Unmarshal(c.ImportedBridges, &cert.ImportedBridgeExits) + } + return cert +} diff --git a/tools/exit_certificate/run_extra_test.go b/tools/exit_certificate/run_extra_test.go new file mode 100644 index 000000000..9fd7d5cba --- /dev/null +++ b/tools/exit_certificate/run_extra_test.go @@ -0,0 +1,166 @@ +package exit_certificate + +import ( + "context" + "path/filepath" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +func TestExpandStepRange(t *testing.T) { + t.Parallel() + // open range stops at the last auto step (sign) + open, err := expandStepRange("0-") + require.NoError(t, err) + require.Equal(t, "0", open[0]) + require.Equal(t, lastAutoStep, open[len(open)-1]) + + // closed range + closed, err := expandStepRange("a1-c") + require.NoError(t, err) + require.Equal(t, []string{"a1", "a2", "b1", "b2", "b3", "c"}, closed) + + // unknown start / end + _, err = expandStepRange("zzz-c") + require.Error(t, err) + _, err = expandStepRange("0-zzz") + require.Error(t, err) + + // reversed range + _, err = expandStepRange("f-c") + require.Error(t, err) +} + +func TestAliasRange(t *testing.T) { + t.Parallel() + require.Equal(t, "a1", aliasRangeStart("a")) + require.Equal(t, "b1", aliasRangeStart("b")) + require.Equal(t, "g1", aliasRangeStart("g")) + require.Equal(t, "x", aliasRangeStart("x")) // passthrough + + require.Equal(t, "a2", aliasRangeEnd("a")) + require.Equal(t, "b3", aliasRangeEnd("b")) + require.Equal(t, "g2", aliasRangeEnd("g")) + require.Equal(t, "x", aliasRangeEnd("x")) +} + +func TestFileExists(t *testing.T) { + t.Parallel() + dir := t.TempDir() + require.False(t, fileExists(filepath.Join(dir, "nope.json"))) + saveJSON(dir, "yes.json", map[string]int{"a": 1}) + require.True(t, fileExists(filepath.Join(dir, "yes.json"))) +} + +func TestLoadTargetBlock(t *testing.T) { + t.Parallel() + dir := t.TempDir() + _, err := loadTargetBlock(dir) // missing → error + require.Error(t, err) + + saveJSON(dir, "step-0-l2_target_block.json", uint64(12345)) + n, err := loadTargetBlock(dir) + require.NoError(t, err) + require.Equal(t, uint64(12345), n) +} + +func TestLoadWrappedTokensFromLBT(t *testing.T) { + t.Parallel() + dir := t.TempDir() + _, err := loadWrappedTokensFromLBT(dir) // missing → error + require.Error(t, err) + + saveJSON(dir, "step-0-lbt.json", []LBTEntry{ + {WrappedTokenAddress: common.BytesToAddress([]byte("wrap")), OriginNetwork: 1, + OriginTokenAddress: common.BytesToAddress([]byte("orig")), Balance: "1000"}, + }) + tokens, err := loadWrappedTokensFromLBT(dir) + require.NoError(t, err) + require.Len(t, tokens, 1) + require.Equal(t, uint32(1), tokens[0].OriginNetwork) +} + +func TestRunSingleStepUnknown(t *testing.T) { + t.Parallel() + cfg := &Config{Options: Options{OutputDir: t.TempDir()}} + err := runSingleStep(context.Background(), "bogus", cfg) + require.Error(t, err) + require.Contains(t, err.Error(), "unknown step") +} + +func TestRunSingleCAndD(t *testing.T) { + t.Parallel() + dir := t.TempDir() + tok := common.BytesToAddress([]byte("wrap")) + orig := common.BytesToAddress([]byte("orig")) + + // Step 0 + B fixtures: LBT supply 1000, accumulated EOA 100 → SC-locked 900. + saveJSON(dir, "step-0-lbt.json", []LBTEntry{ + {WrappedTokenAddress: tok, OriginNetwork: 1, OriginTokenAddress: orig, Balance: "1000"}, + }) + saveJSON(dir, "step-b-accumulated.json", []AccumulatedBalance{ + {WrappedTokenAddress: tok, OriginNetwork: 1, OriginTokenAddress: orig, TotalBalance: "100"}, + }) + saveJSON(dir, "step-b-eoa-balances.json", []EOABalance{ + {Address: common.BytesToAddress([]byte("eoa")), ETHBalance: "0", Tokens: []EOATokenBalance{ + {WrappedTokenAddress: tok, OriginNetwork: 1, OriginTokenAddress: orig, Balance: "100"}, + }}, + }) + + // Step C: pure compute from the fixtures. + require.NoError(t, runSingleC(dir)) + require.True(t, fileExists(filepath.Join(dir, "step-c-sc-locked-values.json"))) + + // Step D: build the certificate from B + C. + cfg := &Config{ + ExitAddress: common.BytesToAddress([]byte("exit")), DestinationNetwork: 0, L2NetworkID: 1, + Options: Options{OutputDir: dir}, + } + require.NoError(t, runSingleD(cfg, dir)) + require.True(t, fileExists(filepath.Join(dir, "step-d-exit-certificate.json"))) + + // dispatch through runSingleStep routes to the same handlers. + require.NoError(t, runSingleStep(context.Background(), "c", cfg)) +} + +func TestRunSingleCMissingInput(t *testing.T) { + t.Parallel() + err := runSingleC(t.TempDir()) // no fixtures → load error + require.Error(t, err) +} + +func TestMigrateStepAToA1(t *testing.T) { + t.Parallel() + dir := t.TempDir() + // no files → no-op, no error + require.NoError(t, migrateStepAToA1(dir)) + + saveJSON(dir, "step-a-addresses.json", []common.Address{common.BytesToAddress([]byte("a"))}) + require.NoError(t, migrateStepAToA1(dir)) + require.True(t, fileExists(filepath.Join(dir, "step-a1-addresses.json"))) + require.False(t, fileExists(filepath.Join(dir, "step-a-addresses.json"))) +} + +func TestSaveStepEFiles(t *testing.T) { + t.Parallel() + dir := t.TempDir() + saveStepEFiles(dir, &StepEResult{ + UnclaimedBridges: []L1Deposit{{DepositCount: 1}}, + UnclaimedMessages: []L1Deposit{{DepositCount: 2}}, + }) + require.True(t, fileExists(filepath.Join(dir, "step-e-unclaimed-bridges.json"))) + require.True(t, fileExists(filepath.Join(dir, "step-e-unclaimed-messages.json"))) +} + +func TestLogPipelineConfig(t *testing.T) { + t.Parallel() + require.NotPanics(t, func() { + logPipelineConfig(&Config{ + L2RPCURL: "http://l2", L1RPCURL: "http://l1", + ExitAddress: common.BytesToAddress([]byte("exit")), + Options: Options{OutputDir: "/tmp/out", BlockRange: 5000}, + }) + }) +} diff --git a/tools/exit_certificate/run_pipeline_test.go b/tools/exit_certificate/run_pipeline_test.go new file mode 100644 index 000000000..135620ae8 --- /dev/null +++ b/tools/exit_certificate/run_pipeline_test.go @@ -0,0 +1,266 @@ +package exit_certificate + +import ( + "context" + "encoding/json" + "flag" + "os" + "path/filepath" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" + "github.com/urfave/cli/v2" +) + +// newRunContext builds a urfave/cli context exposing the flags Run reads (config, step, verbose). +func newRunContext(t *testing.T, args []string) *cli.Context { + t.Helper() + fs := flag.NewFlagSet("test", flag.ContinueOnError) + fs.String("config", "", "") + fs.String("step", "", "") + fs.Bool("verbose", false, "") + require.NoError(t, fs.Parse(args)) + return cli.NewContext(nil, fs, nil) +} + +// writeRunnableConfig writes a minimal-but-valid exit_certificate config whose output dir is dir and +// whose RPC endpoints are unreachable, so pipeline steps fail fast. +func writeRunnableConfig(t *testing.T, dir string) string { + t.Helper() + cfg := `{ + "l2RpcUrl": "http://127.0.0.1:1", + "l1RpcUrl": "http://127.0.0.1:1", + "l2BridgeAddress": "0x1111111111111111111111111111111111111111", + "exitAddress": "0x2222222222222222222222222222222222222222", + "targetBlock": "100", + "options": { + "useAgglayerAdminToStepFCheck": false, + "outputDir": "` + dir + `" + } +}` + path := filepath.Join(t.TempDir(), "config.json") + require.NoError(t, os.WriteFile(path, []byte(cfg), 0o600)) + return path +} + +func TestRunConfigLoadError(t *testing.T) { + t.Parallel() + c := newRunContext(t, []string{"--config", filepath.Join(t.TempDir(), "missing.json")}) + err := Run(c) + require.ErrorContains(t, err, "load config") +} + +func TestRunSingleStepViaRun(t *testing.T) { + t.Parallel() + // step "c" needs a prerequisite file that is absent → Run executes its full body (config load, + // output dir, migrate, parseStepList, runSingleStep) and returns the step's load error. + dir := t.TempDir() + c := newRunContext(t, []string{"--config", writeRunnableConfig(t, dir), "--step", "c"}) + require.Error(t, Run(c)) +} + +func TestRunAllViaRunFailsAtCheck(t *testing.T) { + t.Parallel() + // No --step → runAll, which fails fast at Step CHECK because the RPC endpoints are unreachable. + dir := t.TempDir() + c := newRunContext(t, []string{"--config", writeRunnableConfig(t, dir)}) + require.Error(t, Run(c)) +} + +// pipelineFixtures returns LBT entries and a Step B result that together yield a non-empty Step C/D. +func pipelineFixtures() ([]LBTEntry, *StepBResult) { + tok := common.BytesToAddress([]byte("wrap")) + orig := common.BytesToAddress([]byte("orig")) + lbt := []LBTEntry{ + {WrappedTokenAddress: tok, OriginNetwork: 1, OriginTokenAddress: orig, Balance: "1000"}, + } + b := &StepBResult{ + EOABalances: []EOABalance{ + {Address: common.BytesToAddress([]byte("eoa")), ETHBalance: "0", Tokens: []EOATokenBalance{ + {WrappedTokenAddress: tok, OriginNetwork: 1, OriginTokenAddress: orig, Balance: "100"}, + }}, + }, + Accumulated: []AccumulatedBalance{ + {WrappedTokenAddress: tok, OriginNetwork: 1, OriginTokenAddress: orig, TotalBalance: "100"}, + }, + } + return lbt, b +} + +func TestRunAllStepCAndD(t *testing.T) { + t.Parallel() + dir := t.TempDir() + lbt, bResult := pipelineFixtures() + + cResult, err := runAllStepC(dir, lbt, bResult) + require.NoError(t, err) + require.NotEmpty(t, cResult.SCLockedValues) + require.True(t, fileExists(filepath.Join(dir, fileStepCSCLockedValues))) + + cfg := &Config{ + ExitAddress: common.BytesToAddress([]byte("exit")), DestinationNetwork: 0, L2NetworkID: 1, + Options: Options{OutputDir: dir}, + } + dResult, err := runAllStepD(cfg, dir, bResult, cResult) + require.NoError(t, err) + require.NotNil(t, dResult.Certificate) + require.True(t, fileExists(filepath.Join(dir, fileStepDCertificate))) +} + +func TestRunAllStepCSkippedNoLBT(t *testing.T) { + t.Parallel() + _, bResult := pipelineFixtures() + cResult, err := runAllStepC(t.TempDir(), nil, bResult) + require.NoError(t, err) + require.Empty(t, cResult.SCLockedValues) +} + +func TestRunAllStepESkippedNoL1(t *testing.T) { + t.Parallel() + cert := emptyCert() + out, err := runAllStepE(context.Background(), &Config{}, t.TempDir(), cert) + require.NoError(t, err) + require.Same(t, cert, out) +} + +// TestRunSingleStepDispatchAllSteps drives every step name through runSingleStep with an empty output +// dir and unreachable RPC endpoints: each handler fails fast (missing prerequisite file, or a refused +// RPC connection for the steps that hit the network first). This exercises the full dispatch switch +// and each runSingleX entry/error path without needing a live node. +func TestRunSingleStepDispatchAllSteps(t *testing.T) { + t.Parallel() + steps := []string{ + "check", "0", "a", "a1", "a2", "b", "b1", "b2", "b3", "c", "d", + "e", "f", "g", "g1", "g2", "h", "i", "sign", "submit", "wait", + } + for _, step := range steps { + t.Run(step, func(t *testing.T) { + t.Parallel() + cfg := &Config{ + // Unreachable endpoints so the network-first steps (check, 0) fail fast. + L1RPCURL: "http://127.0.0.1:1", + L2RPCURL: "http://127.0.0.1:1", + L2BridgeAddress: common.HexToAddress("0x1"), + Options: Options{ + OutputDir: t.TempDir(), BlockRange: 5000, RPCBatchSize: 200, ConcurrencyLimit: 4, + }, + } + require.Error(t, runSingleStep(context.Background(), step, cfg)) + }) + } +} + +// TestRunAllStepErrorPaths covers the entry + error-return of the pipeline-step wrappers whose steps +// require a reachable node (or agglayer): with unreachable endpoints each returns its wrapped error. +func TestRunAllStepErrorPaths(t *testing.T) { + t.Parallel() + ctx := context.Background() + cfg := &Config{ + L1RPCURL: "http://127.0.0.1:1", L2RPCURL: "http://127.0.0.1:1", + L2BridgeAddress: common.HexToAddress("0x1"), + Options: Options{OutputDir: t.TempDir(), BlockRange: 5000, RPCBatchSize: 200, ConcurrencyLimit: 4}, + } + + entries, tokens, block, err := resolveOrGenerateLBT(ctx, cfg, cfg.Options.OutputDir) + require.Error(t, err) + require.Nil(t, entries) + require.Nil(t, tokens) + require.Zero(t, block) + + _, err = runAllStepA(ctx, cfg, cfg.Options.OutputDir, 100, nil) + require.Error(t, err) + + // Step B with no addresses to scan completes without touching the node (covers the save path). + _, err = runAllStepB(ctx, cfg, cfg.Options.OutputDir, 100, &StepAResult{}) + require.NoError(t, err) + + _, err = runAllStepG(ctx, cfg, cfg.Options.OutputDir, 100, emptyCert(), nil) + require.Error(t, err) + + // Step H has no agglayer gRPC URL configured → wrapper returns the "required" error. + _, err = runAllStepH(ctx, cfg, cfg.Options.OutputDir, &StepGResult{}) + require.Error(t, err) +} + +func TestRunAllStepFOffline(t *testing.T) { + t.Parallel() + dir := t.TempDir() + // Offline Step F with no LBT data is a benign no-op: it returns the final certificate unchanged + // and contacts no agglayer admin endpoint. + cfg := &Config{Options: Options{OutputDir: dir, UseAgglayerAdminToStepFCheck: false}} + stepD := emptyCert() + final := emptyCert() + + out, err := runAllStepF(context.Background(), cfg, dir, nil, stepD, final) + require.NoError(t, err) + require.Same(t, final, out) + require.True(t, fileExists(filepath.Join(dir, fileStepFChecks))) +} + +func TestRunAllStepIAndRunStepI(t *testing.T) { + t.Parallel() + dir := t.TempDir() + leafCount := uint32(10) + + // topics[1] is the indexed leafCount as a 32-byte big-endian value. + topic1 := common.BytesToHash([]byte{byte(leafCount)}) + srv := newBatchRPCStub(t, func(method string, _ []any) (json.RawMessage, *jsonRPCError) { + switch method { + case rpcMethodEthBlockNumber: + return quoted("0x100"), nil + case rpcMethodEthGetLogs: + out, _ := json.Marshal([]map[string]any{{ + "topics": []string{updateL1InfoTreeV2Topic.Hex(), topic1.Hex()}, + }}) + return out, nil + default: + return quoted("0x"), nil + } + }) + + cfg := &Config{ + L1RPCURL: srv.URL, + L1GlobalExitRootAddress: common.HexToAddress("0x1111111111111111111111111111111111111111"), + Options: Options{OutputDir: dir, BlockRange: 5000}, + } + cert := emptyCert() + gResult := &StepGResult{NewLocalExitRoot: common.HexToHash("0xbeef")} + hResult := &StepHResult{PreviousLocalExitRoot: common.HexToHash("0xabcd"), Height: 3} + + require.NoError(t, runAllStepI(context.Background(), cfg, dir, cert, gResult, hResult)) + require.Equal(t, common.HexToHash("0xbeef"), cert.NewLocalExitRoot) + require.Equal(t, common.HexToHash("0xabcd"), cert.PrevLocalExitRoot) + require.Equal(t, leafCount, cert.L1InfoTreeLeafCount) + require.True(t, fileExists(filepath.Join(dir, fileFinalCertificate))) +} + +func TestRunStepIGuards(t *testing.T) { + t.Parallel() + require.ErrorContains(t, + RunStepI(context.Background(), &Config{}, nil, &StepGResult{}, nil), "certificate is nil") + require.ErrorContains(t, + RunStepI(context.Background(), &Config{}, emptyCert(), nil, nil), "step G result is nil") +} + +func TestRunAllStepEFull(t *testing.T) { + t.Parallel() + dir := t.TempDir() + srv := newBatchRPCStub(t, func(method string, _ []any) (json.RawMessage, *jsonRPCError) { + switch method { + case rpcMethodEthBlockNumber: + return quoted("0x10"), nil + case rpcMethodEthGetLogs: + return json.RawMessage(`[]`), nil + default: + return quoted("0x"), nil + } + }) + cfg := stepEConfig(srv.URL) + cfg.Options.OutputDir = dir + + out, err := runAllStepE(context.Background(), cfg, dir, emptyCert()) + require.NoError(t, err) + require.NotNil(t, out) + require.True(t, fileExists(filepath.Join(dir, fileStepEUnclaimedBridges))) +} diff --git a/tools/exit_certificate/run_stepa_test.go b/tools/exit_certificate/run_stepa_test.go new file mode 100644 index 000000000..c5a0dfcef --- /dev/null +++ b/tools/exit_certificate/run_stepa_test.go @@ -0,0 +1,65 @@ +package exit_certificate + +import ( + "context" + "encoding/json" + "path/filepath" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +// TestRunSingleAChain drives runSingleA1 then runSingleA2 against a stub whose blocks carry no +// transactions, so address collection yields an empty set without any debug_traceTransaction call. +// It covers the run.go Step A wrappers and their file chaining. +func TestRunSingleAChain(t *testing.T) { + t.Parallel() + dir := t.TempDir() + saveJSON(dir, fileStep0TargetBlock, uint64(2)) + + url := newBatchRPCServer(t, func(method string, _ []json.RawMessage) any { + if method == rpcMethodEthGetBlockByNumber { + return map[string]any{"transactions": []string{}} + } + return "0x" + }) + cfg := &Config{ + L2RPCURL: url, L2BridgeAddress: common.BytesToAddress([]byte("bridge")), + Options: Options{ + OutputDir: dir, RPCBatchSize: 10, ConcurrencyLimit: 2, StepAWindowSize: 100, + }, + } + + require.NoError(t, runSingleA1(context.Background(), cfg, dir)) + require.True(t, fileExists(filepath.Join(dir, fileStepA1Addresses))) + require.True(t, fileExists(filepath.Join(dir, fileStepA1FailedTrace))) + + require.NoError(t, runSingleA2(context.Background(), cfg, dir)) + require.True(t, fileExists(filepath.Join(dir, fileStepAAddresses))) + + // runSingleA runs A1 then A2 in sequence. + require.NoError(t, runSingleA(context.Background(), cfg, dir)) +} + +// TestRunAllStepASuccess covers the runAll Step A wrapper (RunStepA1 + RunStepA2) against a stub with +// transaction-free blocks. +func TestRunAllStepASuccess(t *testing.T) { + t.Parallel() + dir := t.TempDir() + url := newBatchRPCServer(t, func(method string, _ []json.RawMessage) any { + if method == rpcMethodEthGetBlockByNumber { + return map[string]any{"transactions": []string{}} + } + return "0x" + }) + cfg := &Config{ + L2RPCURL: url, L2BridgeAddress: common.BytesToAddress([]byte("bridge")), + Options: Options{OutputDir: dir, RPCBatchSize: 10, ConcurrencyLimit: 2, StepAWindowSize: 100}, + } + + res, err := runAllStepA(context.Background(), cfg, dir, 2, nil) + require.NoError(t, err) + require.Empty(t, res.Addresses) + require.True(t, fileExists(filepath.Join(dir, fileStepAAddresses))) +} diff --git a/tools/exit_certificate/run_stepb_test.go b/tools/exit_certificate/run_stepb_test.go new file mode 100644 index 000000000..4157154d2 --- /dev/null +++ b/tools/exit_certificate/run_stepb_test.go @@ -0,0 +1,64 @@ +package exit_certificate + +import ( + "context" + "encoding/json" + "path/filepath" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +// TestRunSingleBChain drives runSingleB1 → B2 → B3 end-to-end against a combined stub, with the +// step-0/step-A prerequisite files written up front. It covers the three Step B run.go wrappers and +// their file chaining (B1 writes the contract-addresses B2/B3 consume). +func TestRunSingleBChain(t *testing.T) { + t.Parallel() + dir := t.TempDir() + rich := common.HexToAddress("0x0000000000000000000000000000000000000001") + poor := common.HexToAddress("0x0000000000000000000000000000000000000002") + tok := common.BytesToAddress([]byte("wrap")) + orig := common.BytesToAddress([]byte("orig")) + + // Prerequisites normally produced by Step 0 and Step A. + saveJSON(dir, fileStep0TargetBlock, uint64(100)) + saveJSON(dir, fileStep0LBT, []LBTEntry{ + {WrappedTokenAddress: tok, OriginNetwork: 1, OriginTokenAddress: orig, Balance: "1000"}, + }) + saveJSON(dir, fileStepAAddresses, []common.Address{rich, poor}) + + url := newBatchRPCServer(t, func(method string, params []json.RawMessage) any { + switch method { + case rpcMethodEthGetCode: + return "0x" // all EOAs, no contracts + case rpcMethodEthGetBalance: + if blockTagOf(t, params) == genesisTag { + return "0x0" // genesis guard passes + } + if firstAddr(t, params) == rich { + return "0x64" + } + return "0x0" + default: + return "0x0" // eth_call balanceOf etc → zero + } + }) + cfg := &Config{ + L2RPCURL: url, L2BridgeAddress: common.BytesToAddress([]byte("bridge")), + L2NetworkID: 1, Options: Options{OutputDir: dir, RPCBatchSize: 10, ConcurrencyLimit: 2}, + } + + require.NoError(t, runSingleB1(context.Background(), cfg, dir)) + require.True(t, fileExists(filepath.Join(dir, fileStepBEOABalances))) + require.True(t, fileExists(filepath.Join(dir, fileStepBContractAddresses))) + + require.NoError(t, runSingleB2(context.Background(), cfg, dir)) + require.True(t, fileExists(filepath.Join(dir, fileStepB2DetectedERC20s))) + + require.NoError(t, runSingleB3(context.Background(), cfg, dir)) + require.True(t, fileExists(filepath.Join(dir, fileStepB3ERC20Holders))) + + // runSingleB runs all three; rerunning is idempotent over the same fixtures. + require.NoError(t, runSingleB(context.Background(), cfg, dir)) +} diff --git a/tools/exit_certificate/run_stepg2_lite_test.go b/tools/exit_certificate/run_stepg2_lite_test.go new file mode 100644 index 000000000..ee3044d58 --- /dev/null +++ b/tools/exit_certificate/run_stepg2_lite_test.go @@ -0,0 +1,81 @@ +package exit_certificate + +import ( + "context" + "encoding/json" + "path/filepath" + "strings" + "testing" + + "github.com/agglayer/aggkit/log" + "github.com/agglayer/aggkit/tools/exit_certificate/bridgesyncerlite" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +// TestRunSingleG2LiteNonEmpty drives runSingleG2 in off-chain (no shadow-fork) mode with a single +// native bridge exit. It builds an empty Step G1 lite DB up front, then serves the bridge getRoot / +// gasTokenMetadata eth_calls so RunStepG2 builds the lite exit tree and writes its outputs. +func TestRunSingleG2LiteNonEmpty(t *testing.T) { + t.Parallel() + dir := t.TempDir() + ctx := context.Background() + + // Empty Step G1 lite DB (genesis→fork bridges = none → cert exits start at deposit count 0). + g1, err := bridgesyncerlite.New(ctx, + bridgesyncerlite.Config{DBPath: filepath.Join(dir, fileStepG1LiteDB)}, log.GetDefaultLogger()) + require.NoError(t, err) + require.NoError(t, g1.Close()) + + // One native bridge exit (token_info origin address zero → gas token). + bridgeExits, err := json.Marshal([]map[string]any{{ + "leaf_type": "Transfer", + "token_info": map[string]any{ + "origin_network": 0, + "origin_token_address": "0x0000000000000000000000000000000000000000", + }, + "dest_network": 0, + "dest_address": "0x1111111111111111111111111111111111111111", + "amount": "1000", + }}) + require.NoError(t, err) + saveJSON(dir, fileStepG1ShadowForkBlock, StepG1Result{ShadowForkBlock: 100}) + saveJSON(dir, fileStepECertificate, &certificateJSON{NetworkID: 1, BridgeExits: bridgeExits}) + + getRootSel := selectorHex(bridgeABI, "getRoot") + gasMetaSel := selectorHex(bridgeABI, "gasTokenMetadata") + rootOut, err := bridgeABI.Methods["getRoot"].Outputs.Pack([32]byte{}) + require.NoError(t, err) + gasMetaOut, err := bridgeABI.Methods["gasTokenMetadata"].Outputs.Pack([]byte{}) + require.NoError(t, err) + + srv := newRPCStub(t, func(method string, params []any) (json.RawMessage, *jsonRPCError) { + if method != rpcMethodEthCall { + return quoted("0x"), nil // eth_getCode and any other probe → empty + } + call, _ := params[0].(map[string]any) + data, _ := call["data"].(string) + data = strings.TrimPrefix(data, "0x") + switch { + case strings.HasPrefix(data, getRootSel): + return hexResult(rootOut), nil + case strings.HasPrefix(data, gasMetaSel): + return hexResult(gasMetaOut), nil + default: + // gasTokenNetwork/gasTokenAddress: an empty result makes fetchGasTokenInfo fall back to the + // ETH default (network 0, zero address), which is what this native exit expects. + return quoted("0x"), nil + } + }) + + cfg := &Config{ + L2RPCURL: srv.URL, + L2BridgeAddress: common.HexToAddress("0x2222222222222222222222222222222222222222"), + L2NetworkID: 1, + Options: Options{OutputDir: dir, VerifyNewLocalExitRootUsingShadowFork: false}, + } + + require.NoError(t, runSingleG2(ctx, cfg, dir)) + require.True(t, fileExists(filepath.Join(dir, fileStepGNewLocalExitRoot))) + require.True(t, fileExists(filepath.Join(dir, fileStepGReorderedCertificate))) +} diff --git a/tools/exit_certificate/run_steps_success_test.go b/tools/exit_certificate/run_steps_success_test.go new file mode 100644 index 000000000..94780cd58 --- /dev/null +++ b/tools/exit_certificate/run_steps_success_test.go @@ -0,0 +1,92 @@ +package exit_certificate + +import ( + "context" + "encoding/json" + "path/filepath" + "testing" + + aggkittypes "github.com/agglayer/aggkit/types" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +// step0SuccessConfig wires a Config to a step0Stub so RunStep0 (and its run.go wrappers) succeed. +func step0SuccessConfig(t *testing.T, dir string) *Config { + t.Helper() + url := step0Stub(t, makeWrappedTokenData(1, + common.BytesToAddress([]byte("origin")), common.BytesToAddress([]byte("wrapped")))) + return &Config{ + L2RPCURL: url, + L2BridgeAddress: common.BytesToAddress([]byte("bridge")), + TargetBlock: *aggkittypes.NewBlockNumber(100), + Options: Options{OutputDir: dir, BlockRange: 50, ConcurrencyLimit: 2, RPCBatchSize: 10}, + } +} + +func TestRunSingle0Success(t *testing.T) { + t.Parallel() + dir := t.TempDir() + cfg := step0SuccessConfig(t, dir) + + require.NoError(t, runSingle0(context.Background(), cfg, dir)) + require.True(t, fileExists(filepath.Join(dir, fileStep0TargetBlock))) + require.True(t, fileExists(filepath.Join(dir, fileStep0LBT))) + + // Dispatch through runSingleStep routes to the same handler. + require.NoError(t, runSingleStep(context.Background(), "0", cfg)) +} + +func TestRunSingleFOffline(t *testing.T) { + t.Parallel() + dir := t.TempDir() + saveJSON(dir, fileStepDCertificate, map[string]any{"network_id": 1}) + cfg := &Config{Options: Options{OutputDir: dir, UseAgglayerAdminToStepFCheck: false}} + + require.NoError(t, runSingleF(context.Background(), cfg, dir)) + require.True(t, fileExists(filepath.Join(dir, fileStepFChecks))) +} + +func TestRunSingleISuccess(t *testing.T) { + t.Parallel() + dir := t.TempDir() + saveJSON(dir, fileStepGReorderedCertificate, map[string]any{"network_id": 1}) + saveJSON(dir, fileStepGNewLocalExitRoot, StepGResult{NewLocalExitRoot: common.HexToHash("0xbeef")}) + saveJSON(dir, fileStepHPreviousLocalExitRoot, StepHResult{PreviousLocalExitRoot: common.HexToHash("0xabcd"), Height: 2}) + + topic1 := common.BytesToHash([]byte{0x0a}) + srv := newBatchRPCStub(t, func(method string, _ []any) (json.RawMessage, *jsonRPCError) { + switch method { + case rpcMethodEthBlockNumber: + return quoted("0x100"), nil + case rpcMethodEthGetLogs: + out, _ := json.Marshal([]map[string]any{{ + "topics": []string{updateL1InfoTreeV2Topic.Hex(), topic1.Hex()}, + }}) + return out, nil + default: + return quoted("0x"), nil + } + }) + cfg := &Config{ + L1RPCURL: srv.URL, + L1GlobalExitRootAddress: common.HexToAddress("0x1111111111111111111111111111111111111111"), + Options: Options{OutputDir: dir, BlockRange: 5000}, + } + + require.NoError(t, runSingleI(context.Background(), cfg, dir)) + require.True(t, fileExists(filepath.Join(dir, fileFinalCertificate))) +} + +func TestResolveOrGenerateLBTSuccess(t *testing.T) { + t.Parallel() + dir := t.TempDir() + cfg := step0SuccessConfig(t, dir) + + entries, tokens, targetBlock, err := resolveOrGenerateLBT(context.Background(), cfg, dir) + require.NoError(t, err) + require.Equal(t, uint64(100), targetBlock) + require.NotEmpty(t, entries) + require.NotEmpty(t, tokens) + require.True(t, fileExists(filepath.Join(dir, fileStep0LBT))) +} diff --git a/tools/exit_certificate/run_steps_test.go b/tools/exit_certificate/run_steps_test.go new file mode 100644 index 000000000..587c2db36 --- /dev/null +++ b/tools/exit_certificate/run_steps_test.go @@ -0,0 +1,121 @@ +package exit_certificate + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +// TestRunSingleMissingInputs covers the load-from-disk guard of each single-step orchestrator that +// reads a prerequisite file before doing any RPC: with an empty output dir they fail fast. +func TestRunSingleMissingInputs(t *testing.T) { + t.Parallel() + ctx := context.Background() + + cases := map[string]func(dir string) error{ + "submit": func(dir string) error { return runSingleSubmit(ctx, &Config{}, dir) }, + "wait": func(dir string) error { return runSingleWait(ctx, &Config{}, dir) }, + "f": func(dir string) error { return runSingleF(ctx, &Config{}, dir) }, + "h": func(dir string) error { return runSingleH(ctx, &Config{}, dir) }, + "i": func(dir string) error { return runSingleI(ctx, &Config{}, dir) }, + "g1": func(dir string) error { return runSingleG1(ctx, &Config{}, dir) }, + "g2": func(dir string) error { return runSingleG2(ctx, &Config{}, dir) }, + } + for name, fn := range cases { + t.Run(name, func(t *testing.T) { + t.Parallel() + require.Error(t, fn(t.TempDir())) + }) + } +} + +func TestRunSingleE_RequiresL1RPC(t *testing.T) { + t.Parallel() + err := runSingleE(context.Background(), &Config{}, t.TempDir()) + require.Error(t, err) + require.Contains(t, err.Error(), "l1RpcUrl") +} + +func TestRunSingleSign(t *testing.T) { + t.Parallel() + ctx := context.Background() + + t.Run("missing certificate", func(t *testing.T) { + t.Parallel() + err := runSingleSign(ctx, &Config{}, t.TempDir()) + require.Error(t, err) + require.Contains(t, err.Error(), "load final certificate") + }) + + t.Run("requires signer method", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + saveJSON(dir, fileFinalCertificate, map[string]any{}) // valid empty cert + err := runSingleSign(ctx, &Config{Options: Options{OutputDir: dir}}, dir) + require.Error(t, err) + require.Contains(t, err.Error(), "signerConfig.Method") + }) +} + +func TestResolveLatestBlock(t *testing.T) { + t.Parallel() + + t.Run("success", func(t *testing.T) { + t.Parallel() + srv := newRPCStub(t, func(method string, _ []any) (json.RawMessage, *jsonRPCError) { + require.Equal(t, "eth_blockNumber", method) + return quoted("0x1a4"), nil + }) + n, err := resolveLatestBlock(context.Background(), srv.URL) + require.NoError(t, err) + require.Equal(t, uint64(420), n) + }) + + t.Run("rpc error", func(t *testing.T) { + t.Parallel() + srv := newRPCStub(t, func(string, []any) (json.RawMessage, *jsonRPCError) { + return nil, revertErr() + }) + _, err := resolveLatestBlock(context.Background(), srv.URL) + require.Error(t, err) + }) +} + +// TestRunSingleG2_EmptyCertificate drives runSingleG2 end-to-end without Anvil: a certificate with no +// bridge exits short-circuits RunStepG2 to the canonical EmptyLER (it only reads the initial LER from +// the L2 RPC, which the stub serves), so both output files are written. +func TestRunSingleG2_EmptyCertificate(t *testing.T) { + t.Parallel() + dir := t.TempDir() + bridge := common.HexToAddress("0xabcabcabcabcabcabcabcabcabcabcabcabcabca") + + // Stub L2 RPC: getRoot() returns a zero root so readLocalExitRoot succeeds (no retries/backoff). + rootOut, err := bridgeABI.Methods["getRoot"].Outputs.Pack([32]byte{}) + require.NoError(t, err) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req jsonRPCRequest + require.NoError(t, json.NewDecoder(r.Body).Decode(&req)) + resp := jsonRPCResponse{JSONRPC: "2.0", ID: req.ID, Result: hexResult(rootOut)} + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + })) + defer srv.Close() + + saveJSON(dir, fileStepG1ShadowForkBlock, StepG1Result{ShadowForkBlock: 100}) + saveJSON(dir, fileStepECertificate, map[string]any{}) // empty cert → no bridge exits + + cfg := &Config{ + L2RPCURL: srv.URL, + L2BridgeAddress: bridge, + Options: Options{OutputDir: dir, VerifyNewLocalExitRootUsingShadowFork: false}, + } + require.NoError(t, runSingleG2(context.Background(), cfg, dir)) + require.True(t, fileExists(filepath.Join(dir, fileStepGNewLocalExitRoot))) + require.True(t, fileExists(filepath.Join(dir, fileStepGReorderedCertificate))) +} diff --git a/tools/exit_certificate/run_test.go b/tools/exit_certificate/run_test.go new file mode 100644 index 000000000..5d0f5ec17 --- /dev/null +++ b/tools/exit_certificate/run_test.go @@ -0,0 +1,145 @@ +package exit_certificate + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +func TestParseStepList(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + want []string + wantErr bool + }{ + {"single step", "f", []string{"f"}, false}, + {"comma list", "h, i, sign", []string{"h", "i", "sign"}, false}, + {"closed range", "f-i", []string{"f", "g1", "g2", "h", "i"}, false}, + {"open range", "f-", []string{"f", "g1", "g2", "h", "i", "sign"}, false}, + {"open range from sign", "sign-", []string{"sign"}, false}, + {"open range from submit includes wait", "submit-", []string{"submit", "wait"}, false}, + {"open range from wait", "wait-", []string{"wait"}, false}, + {"single-step range", "g-g", []string{"g1", "g2"}, false}, + {"g alias expands to g1 g2", "g", []string{"g1", "g2"}, false}, + {"g-h range expands g to g1 g2", "g-h", []string{"g1", "g2", "h"}, false}, + {"explicit range including submit", "sign-submit", []string{"sign", "submit"}, false}, + {"reversed range error", "i-f", nil, true}, + {"unknown from step", "z-i", nil, true}, + {"unknown to step", "f-z", nil, true}, + // Step A and B alias and sub-step expansion. + {"a alias expands to a1 a2", "a", []string{"a1", "a2"}, false}, + {"b alias expands to b1 b2 b3", "b", []string{"b1", "b2", "b3"}, false}, + {"a-b expands a to a1 a2 and b to b1 b2 b3", "a-b", []string{"a1", "a2", "b1", "b2", "b3"}, false}, + {"a2-b range expands b to b1 b2 b3", "a2-b", []string{"a2", "b1", "b2", "b3"}, false}, + {"b-c range expands b to b1 b2 b3", "b-c", []string{"b1", "b2", "b3", "c"}, false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got, err := parseStepList(tc.input) + if tc.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + require.Equal(t, tc.want, got) + } + }) + } +} + +func TestSaveAndLoadJSON(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + testData := []string{"hello", "world"} + + saveJSON(dir, "test.json", testData) + + var loaded []string + err := loadJSON(dir, "test.json", &loaded) + require.NoError(t, err) + require.Equal(t, testData, loaded) +} + +func TestLoadJSON_FileNotFound(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + var target []string + err := loadJSON(dir, "nonexistent.json", &target) + require.Error(t, err) +} + +func TestLoadJSON_InvalidJSON(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + path := filepath.Join(dir, "bad.json") + require.NoError(t, os.WriteFile(path, []byte("{bad}"), 0o600)) + + var target map[string]string + err := loadJSON(dir, "bad.json", &target) + require.Error(t, err) +} + +func TestCertificateJSON_ToAgglayerCertificate(t *testing.T) { + t.Parallel() + + bridgeExitsJSON, _ := json.Marshal([]map[string]any{ + { + "leaf_type": "Transfer", + "token_info": map[string]any{ + "origin_network": 0, + "origin_token_address": "0x0000000000000000000000000000000000000000", + }, + "dest_network": 0, + "dest_address": "0x1111111111111111111111111111111111111111", + "amount": "1000", + }, + }) + + certJSON := &certificateJSON{ + NetworkID: 1, + BridgeExits: bridgeExitsJSON, + } + + cert := certJSON.toAgglayerCertificate() + require.Equal(t, uint32(1), cert.NetworkID) + require.Len(t, cert.BridgeExits, 1) +} + +func TestCertificateJSON_EmptyBridgeExits(t *testing.T) { + t.Parallel() + + certJSON := &certificateJSON{NetworkID: 1} + cert := certJSON.toAgglayerCertificate() + require.Equal(t, uint32(1), cert.NetworkID) + require.Empty(t, cert.BridgeExits) +} + +func TestSaveJSON_ComplexData(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + data := map[string]any{ + "address": common.HexToAddress("0x1234").Hex(), + "balance": "1000000", + } + + saveJSON(dir, "complex.json", data) + + content, err := os.ReadFile(filepath.Join(dir, "complex.json")) + require.NoError(t, err) + + var loaded map[string]any + require.NoError(t, json.Unmarshal(content, &loaded)) + require.Equal(t, "1000000", loaded["balance"]) +} diff --git a/tools/exit_certificate/scripts/bridge_l1_to_l2.sh b/tools/exit_certificate/scripts/bridge_l1_to_l2.sh new file mode 100755 index 000000000..81534d0b8 --- /dev/null +++ b/tools/exit_certificate/scripts/bridge_l1_to_l2.sh @@ -0,0 +1,428 @@ +#!/usr/bin/env bash +# Bridges ETH (or an ERC-20) from L1 to L2 by calling bridgeAsset on the L1 bridge +# contract in a running Kurtosis enclave. Requires: kurtosis, cast (Foundry). +set -euo pipefail + +GREEN='\033[0;32m' +RED='\033[0;31m' +ORANGE='\033[0;33m' +NC='\033[0m' + +log_info() { echo -e "${GREEN}[INFO]${NC} $*" >&2; } +log_warn() { echo -e "${ORANGE}[WARN]${NC} $*" >&2; } +log_error() { echo -e "${RED}[ERROR]${NC} $*" >&2; } + +usage() { + cat >&2 </dev/null || missing+=("kurtosis") + command -v cast &>/dev/null || missing+=("cast (foundry)") + if [[ ${#missing[@]} -gt 0 ]]; then + log_error "Missing required tools: ${missing[*]}" + log_error "Install Foundry: https://getfoundry.sh" + exit 1 + fi +} + +# --------------------------------------------------------------------------- +# Kurtosis helpers +# --------------------------------------------------------------------------- + +port_to_localhost_url() { + local raw_url="$1" + local port + port=$(echo "$raw_url" | sed -E 's|^[a-zA-Z]+://||' | cut -f2 -d':') + echo "http://localhost:${port}" +} + +get_l1_rpc_url() { + local raw + if ! raw=$(kurtosis port print "$KURTOSIS_ENCLAVE" "$L1_SERVICE" rpc 2>/dev/null); then + log_error "Failed to get L1 RPC port from service '$L1_SERVICE' in enclave '$KURTOSIS_ENCLAVE'" + log_error "Ensure the enclave is running: kurtosis enclave inspect $KURTOSIS_ENCLAVE" + exit 1 + fi + port_to_localhost_url "$raw" +} + +get_l2_rpc_url() { + local raw + if ! raw=$(kurtosis port print "$KURTOSIS_ENCLAVE" "$L2_SERVICE" rpc 2>/dev/null); then + log_error "Failed to get L2 RPC port from service '$L2_SERVICE' in enclave '$KURTOSIS_ENCLAVE'" + log_error "To use a different prefix, set L2_SERVICE_PREFIX" + exit 1 + fi + port_to_localhost_url "$raw" +} + +get_bridge_address() { + local tmp_dir + tmp_dir=$(mktemp -d) + # shellcheck disable=SC2064 + trap "rm -rf '$tmp_dir'" RETURN + + local artifact_name="${KURTOSIS_ARTIFACT_AGGKIT_CONFIG}-${NETWORK_SUFFIX}" + if ! kurtosis files download "$KURTOSIS_ENCLAVE" "$artifact_name" "$tmp_dir" &>/dev/null; then + log_warn "Artifact '$artifact_name' not found, trying '$KURTOSIS_ARTIFACT_AGGKIT_CONFIG'..." + artifact_name="$KURTOSIS_ARTIFACT_AGGKIT_CONFIG" + if ! kurtosis files download "$KURTOSIS_ENCLAVE" "$artifact_name" "$tmp_dir" &>/dev/null; then + log_error "Could not download artifact '$artifact_name' from enclave '$KURTOSIS_ENCLAVE'" + exit 1 + fi + fi + + local config_file="$tmp_dir/config.toml" + if [[ ! -f "$config_file" ]]; then + log_error "config.toml not found in downloaded artifact '$artifact_name'" + exit 1 + fi + + local addr + addr=$(grep 'BridgeAddr' "$config_file" | head -1 | tr -d '[:space:]' | cut -f2 -d'=' | tr -d '"') + if [[ -z "$addr" ]]; then + log_error "BridgeAddr not found in $config_file" + exit 1 + fi + echo "$addr" +} + +# --------------------------------------------------------------------------- +# bridgeAsset ABI +# +# function bridgeAsset( +# uint32 destinationNetwork, +# address destinationAddress, +# uint256 amount, +# address token, +# bool forceUpdate, +# bytes calldata permitData +# ) external payable +# --------------------------------------------------------------------------- + +# wait_for_claim polls isClaimed(depositCount, sourceBridgeNetwork=0) on the L2 bridge. +# depositCount is extracted from the DepositCount field of the BridgeEvent emitted in the tx. +wait_for_claim() { + local l2_rpc="$1" + local l2_bridge="$2" + local deposit_count="$3" + local timeout_secs="${4:-300}" + local poll_secs=5 + + # isClaimed(uint32 leafIndex, uint32 sourceBridgeNetwork) — selector: 0xcc461632 + local leaf_idx_hex + local src_net_hex + leaf_idx_hex=$(printf '%064x' "$deposit_count") + src_net_hex=$(printf '%064x' 0) + local calldata="0xcc461632${leaf_idx_hex}${src_net_hex}" + + log_info "Waiting for claim on L2 (depositCount=$deposit_count, timeout=${timeout_secs}s)..." + + local elapsed=0 + while [[ $elapsed -lt $timeout_secs ]]; do + local result + result=$(cast call --rpc-url "$l2_rpc" "$l2_bridge" "$calldata" 2>/dev/null || true) + # isClaimed returns a non-zero uint256 when claimed + local val + val=$(cast --to-dec "${result:-0x0}" 2>/dev/null || echo "0") + if [[ "$val" != "0" ]]; then + log_info "Deposit claimed on L2! (depositCount=$deposit_count)" + return 0 + fi + sleep "$poll_secs" + elapsed=$((elapsed + poll_secs)) + log_info " Still waiting... ${elapsed}s / ${timeout_secs}s" + done + + log_warn "Timed out waiting for claim after ${timeout_secs}s" + return 1 +} + +# extract_deposit_count parses the DepositCount from a BridgeEvent log in a tx receipt. +# BridgeEvent topic: keccak256("BridgeEvent(uint8,uint32,address,uint32,address,uint256,bytes,uint32)") +extract_deposit_count() { + local tx_hash="$1" + local l1_rpc="$2" + + local bridge_event_topic="0x501781209a1f8899323b96b4ef08b168df93e0a90c673d1e4cce39366cb62f9b" + + local receipt + receipt=$(cast receipt --rpc-url "$l1_rpc" "$tx_hash" --json 2>/dev/null || true) + if [[ -z "$receipt" ]]; then + log_warn "Could not fetch receipt for $tx_hash — skipping claim wait" + echo "" + return + fi + + # Find the BridgeEvent log and decode depositCount from the ABI-encoded data. + # Layout (32-byte words): leafType | originNetwork | originAddress | destNetwork | + # destAddress | amount | metadataOffset | depositCount | ... + local data + data=$(echo "$receipt" | python3 -c " +import sys, json +receipt = json.load(sys.stdin) +topic = '$bridge_event_topic' +for log in receipt.get('logs', []): + if log.get('topics', [None])[0] == topic: + print(log.get('data', '')) + break +" 2>/dev/null || true) + + if [[ -z "$data" ]]; then + log_warn "BridgeEvent log not found in tx $tx_hash" + echo "" + return + fi + + # depositCount is the 8th 32-byte word (offset 7*32=224 bytes, 0x prefix stripped) + local hex_data="${data#0x}" + local deposit_count_hex="${hex_data:448:64}" # word 7 (0-indexed): 7*64=448 chars + local deposit_count + deposit_count=$(python3 -c "print(int('$deposit_count_hex', 16))" 2>/dev/null || echo "") + echo "$deposit_count" +} + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +check_deps + +log_info "Enclave: $KURTOSIS_ENCLAVE" +log_info "Network index: $NETWORK_INDEX (suffix: $NETWORK_SUFFIX)" +log_info "L1 service: $L1_SERVICE" +log_info "L2 service: $L2_SERVICE" +log_info "Amount: $BRIDGE_AMOUNT wei" +log_info "Token: $TOKEN_ADDRESS" + +log_info "Getting L1 RPC URL..." +L1_RPC_URL=$(get_l1_rpc_url) +log_info "L1 RPC URL: $L1_RPC_URL" + +log_info "Getting bridge address from aggkit config artifact..." +BRIDGE_ADDR=$(get_bridge_address) +log_info "Bridge address: $BRIDGE_ADDR" + +# Derive sender address from private key +SENDER_ADDR=$(cast wallet address --private-key "$PRIVATE_KEY") +log_info "Sender address: $SENDER_ADDR" + +# Use sender as destination if not specified +if [[ -z "$DEST_ADDRESS" ]]; then + DEST_ADDRESS="$SENDER_ADDR" +fi +log_info "Destination: $DEST_ADDRESS (network $NETWORK_INDEX)" + +# Check sender balance +SENDER_BALANCE=$(cast balance --rpc-url "$L1_RPC_URL" "$SENDER_ADDR") +SENDER_BALANCE_ETH=$(cast --from-wei "$SENDER_BALANCE" ether) +log_info "Sender L1 balance: $SENDER_BALANCE_ETH ETH ($SENDER_BALANCE wei)" + +IS_ETH_BRIDGE="false" +if [[ "$TOKEN_ADDRESS" == "0x0000000000000000000000000000000000000000" ]]; then + IS_ETH_BRIDGE="true" +fi + +# bash integer arithmetic overflows for wei values > 2^63; use python3 for the comparison. +if [[ "$IS_ETH_BRIDGE" == "true" ]] && python3 -c "import sys; sys.exit(0 if int('$SENDER_BALANCE') < int('$BRIDGE_AMOUNT') else 1)"; then + BRIDGE_AMOUNT_ETH=$(cast --from-wei "$BRIDGE_AMOUNT" ether) + log_error "Insufficient balance: sender has $SENDER_BALANCE_ETH ETH, needs $BRIDGE_AMOUNT_ETH ETH" + exit 1 +fi + +# --------------------------------------------------------------------------- +# For ERC-20: approve the bridge contract first +# --------------------------------------------------------------------------- + +if [[ "$IS_ETH_BRIDGE" != "true" ]]; then + log_info "ERC-20 bridge: approving bridge contract to spend $BRIDGE_AMOUNT of $TOKEN_ADDRESS..." + APPROVE_TX=$(cast send \ + --rpc-url "$L1_RPC_URL" \ + --private-key "$PRIVATE_KEY" \ + "$TOKEN_ADDRESS" \ + "approve(address,uint256)" \ + "$BRIDGE_ADDR" \ + "$BRIDGE_AMOUNT") + log_info "Approve tx: $APPROVE_TX" +fi + +# --------------------------------------------------------------------------- +# Call bridgeAsset +# +# bridgeAsset( +# uint32 destinationNetwork, → NETWORK_INDEX +# address destinationAddress, → DEST_ADDRESS +# uint256 amount, → BRIDGE_AMOUNT +# address token, → TOKEN_ADDRESS (0x0 for ETH) +# bool forceUpdate, → true (forces local exit root update) +# bytes permitData → 0x (empty) +# ) +# msg.value = BRIDGE_AMOUNT for ETH; 0 for ERC-20 +# --------------------------------------------------------------------------- + +log_info "Calling bridgeAsset on L1 bridge..." + +if [[ "$IS_ETH_BRIDGE" == "true" ]]; then + TX_HASH=$(cast send \ + --rpc-url "$L1_RPC_URL" \ + --private-key "$PRIVATE_KEY" \ + --value "$BRIDGE_AMOUNT" \ + --json \ + "$BRIDGE_ADDR" \ + "bridgeAsset(uint32,address,uint256,address,bool,bytes)" \ + "$NETWORK_INDEX" \ + "$DEST_ADDRESS" \ + "$BRIDGE_AMOUNT" \ + "0x0000000000000000000000000000000000000000" \ + true \ + "0x" | python3 -c "import sys,json; print(json.load(sys.stdin)['transactionHash'])") +else + TX_HASH=$(cast send \ + --rpc-url "$L1_RPC_URL" \ + --private-key "$PRIVATE_KEY" \ + --json \ + "$BRIDGE_ADDR" \ + "bridgeAsset(uint32,address,uint256,address,bool,bytes)" \ + "$NETWORK_INDEX" \ + "$DEST_ADDRESS" \ + "$BRIDGE_AMOUNT" \ + "$TOKEN_ADDRESS" \ + true \ + "0x" | python3 -c "import sys,json; print(json.load(sys.stdin)['transactionHash'])") +fi + +log_info "Bridge tx hash: $TX_HASH" + +log_info "Waiting for receipt..." +TX_STATUS=$(cast receipt --rpc-url "$L1_RPC_URL" "$TX_HASH" status 2>/dev/null || true) +TX_BLOCK=$(cast receipt --rpc-url "$L1_RPC_URL" "$TX_HASH" blockNumber 2>/dev/null || true) +if [[ -z "$TX_STATUS" ]]; then + log_warn "Could not fetch receipt for $TX_HASH" +elif [[ "$TX_STATUS" == *"success"* ]]; then + log_info "Receipt: status=success blockNumber=$TX_BLOCK" +else + log_error "Receipt: status=REVERTED blockNumber=$TX_BLOCK" + log_error "Replaying transaction to get revert reason..." + cast run --rpc-url "$L1_RPC_URL" "$TX_HASH" 2>&1 | grep -E "revert|Revert|error|Error|←" | head -20 >&2 + exit 1 +fi + +log_info "Bridge from L1 to L2 network $NETWORK_INDEX submitted successfully." +log_info " Sender: $SENDER_ADDR" +log_info " Destination: $DEST_ADDRESS" +log_info " Amount: $BRIDGE_AMOUNT wei" +log_info " Token: $TOKEN_ADDRESS" +log_info " Bridge: $BRIDGE_ADDR" + +# --------------------------------------------------------------------------- +# Optionally wait for the deposit to be auto-claimed on L2 +# --------------------------------------------------------------------------- + +if [[ "$WAIT_FOR_CLAIM" == "true" ]]; then + log_info "Getting L2 RPC URL..." + L2_RPC_URL=$(get_l2_rpc_url) + log_info "L2 RPC URL: $L2_RPC_URL" + + DEPOSIT_COUNT=$(extract_deposit_count "$TX_HASH" "$L1_RPC_URL") + if [[ -n "$DEPOSIT_COUNT" ]]; then + wait_for_claim "$L2_RPC_URL" "$BRIDGE_ADDR" "$DEPOSIT_COUNT" 300 + else + log_warn "Could not determine depositCount — skipping claim check" + fi +fi diff --git a/tools/exit_certificate/scripts/configuration_based_on_kurtosis.sh b/tools/exit_certificate/scripts/configuration_based_on_kurtosis.sh new file mode 100755 index 000000000..23872984f --- /dev/null +++ b/tools/exit_certificate/scripts/configuration_based_on_kurtosis.sh @@ -0,0 +1,476 @@ +#!/usr/bin/env bash +# Creates tmp/exit_certificate-kurtosis.json from a running Kurtosis enclave. +# Uses kurtosis port print and files download to extract RPC URLs and the bridge address. +set -euo pipefail + +GREEN='\033[0;32m' +RED='\033[0;31m' +ORANGE='\033[0;33m' +NC='\033[0m' + +log_info() { echo -e "${GREEN}[INFO]${NC} $*" >&2; } +log_warn() { echo -e "${ORANGE}[WARN]${NC} $*" >&2; } +log_error() { echo -e "${RED}[ERROR]${NC} $*" >&2; } + +usage() { + cat >&2 < "$_privkey_file" + chmod 600 "$_privkey_file" + log_info "Exit address private key saved to: $_privkey_file" + fi + if [[ ! -f "$_keystore_file" ]]; then + printf '%s\n' "$_EXIT_KEYSTORE_DEFAULT" > "$_keystore_file" + chmod 600 "$_keystore_file" + log_info "Exit address keystore saved to: $_keystore_file (password: $_EXIT_KEYSTORE_PASSWORD)" + fi +fi +OUTPUT_FILE="${OUTPUT_FILE:-tmp/exit_certificate-kurtosis.json}" +NETWORK_INDEX=1 + +# Parse flags and positional args +while [[ $# -gt 0 ]]; do + case $1 in + -h|--help) usage ;; + -e|--enclave) + [[ $# -lt 2 ]] && { log_error "--enclave requires a value"; usage; } + KURTOSIS_ENCLAVE="$2"; shift 2 ;; + -o|--output) + [[ $# -lt 2 ]] && { log_error "--output requires a value"; usage; } + OUTPUT_FILE="$2"; shift 2 ;; + [0-9]*) + NETWORK_INDEX="$1"; shift ;; + *) + log_error "Unknown argument: $1"; usage ;; + esac +done + +NETWORK_SUFFIX=$(printf '%03d' "$NETWORK_INDEX") +L2_SERVICE="${L2_SERVICE_PREFIX}-${NETWORK_SUFFIX}" +ZKEVM_BRIDGE_SERVICE="${ZKEVM_BRIDGE_SERVICE_PREFIX}-${NETWORK_SUFFIX}" +OUTPUT_PATH="$PROJECT_ROOT/$OUTPUT_FILE" + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +# kurtosis port print returns something like "http://127.0.0.1:PORT" +# Extract port and rebuild as http://localhost:PORT. +port_to_localhost_url() { + local raw_url="$1" + local port + port=$(echo "$raw_url" | sed -E 's|^[a-zA-Z]+://||' | cut -f2 -d':') + echo "http://localhost:${port}" +} + +get_l1_rpc_url() { + local raw + if ! raw=$(kurtosis port print "$KURTOSIS_ENCLAVE" "$L1_SERVICE" rpc 2>/dev/null); then + log_error "Failed to get L1 RPC port from service '$L1_SERVICE' in enclave '$KURTOSIS_ENCLAVE'" + log_error "Ensure the enclave is running: kurtosis enclave inspect $KURTOSIS_ENCLAVE" + exit 1 + fi + port_to_localhost_url "$raw" +} + +get_l2_rpc_url() { + local raw + if ! raw=$(kurtosis port print "$KURTOSIS_ENCLAVE" "$L2_SERVICE" rpc 2>/dev/null); then + log_error "Failed to get L2 RPC port from service '$L2_SERVICE' in enclave '$KURTOSIS_ENCLAVE'" + log_error "To use a different prefix (e.g. cdk-erigon-sequencer), set L2_SERVICE_PREFIX" + exit 1 + fi + port_to_localhost_url "$raw" +} + +get_agglayer_admin_url() { + local raw + if ! raw=$(kurtosis port print "$KURTOSIS_ENCLAVE" "$AGGLAYER_SERVICE" aglr-admin 2>/dev/null); then + log_error "Failed to get agglayer admin port from service '$AGGLAYER_SERVICE' in enclave '$KURTOSIS_ENCLAVE'" + exit 1 + fi + port_to_localhost_url "$raw" +} + +get_agglayer_grpc_url() { + local raw port + if ! raw=$(kurtosis port print "$KURTOSIS_ENCLAVE" "$AGGLAYER_SERVICE" aglr-grpc 2>/dev/null); then + log_error "Failed to get agglayer grpc port from service '$AGGLAYER_SERVICE' in enclave '$KURTOSIS_ENCLAVE'" + exit 1 + fi + # kurtosis returns grpc://host:PORT — rebuild as http://localhost:PORT (insecure gRPC) + port=$(echo "$raw" | sed -E 's|^[a-zA-Z]+://||' | cut -f2 -d':') + echo "http://localhost:${port}" +} + +get_zkevm_bridge_service_url() { + local raw + if ! raw=$(kurtosis port print "$KURTOSIS_ENCLAVE" "$ZKEVM_BRIDGE_SERVICE" rpc 2>/dev/null); then + return 1 + fi + port_to_localhost_url "$raw" +} + +# --------------------------------------------------------------------------- +# Aggkit config artifact — downloaded once, reused by multiple functions +# --------------------------------------------------------------------------- + +AGGKIT_CONFIG_DIR="" + +download_aggkit_config() { + AGGKIT_CONFIG_DIR=$(mktemp -d) + + local artifact_name="${KURTOSIS_ARTIFACT_AGGKIT_CONFIG}-${NETWORK_SUFFIX}" + if ! kurtosis files download "$KURTOSIS_ENCLAVE" "$artifact_name" "$AGGKIT_CONFIG_DIR" &>/dev/null; then + log_warn "Artifact '$artifact_name' not found, trying '$KURTOSIS_ARTIFACT_AGGKIT_CONFIG'..." + artifact_name="$KURTOSIS_ARTIFACT_AGGKIT_CONFIG" + if ! kurtosis files download "$KURTOSIS_ENCLAVE" "$artifact_name" "$AGGKIT_CONFIG_DIR" &>/dev/null; then + log_error "Could not download artifact '$artifact_name' from enclave '$KURTOSIS_ENCLAVE'" + exit 1 + fi + fi + + if [[ ! -f "$AGGKIT_CONFIG_DIR/config.toml" ]]; then + log_error "config.toml not found in downloaded artifact '$artifact_name'" + exit 1 + fi +} + +cleanup_aggkit_config() { + [[ -n "$AGGKIT_CONFIG_DIR" ]] && rm -rf "$AGGKIT_CONFIG_DIR" +} + +get_bridge_address() { + local addr + addr=$(grep 'BridgeAddr' "$AGGKIT_CONFIG_DIR/config.toml" | head -1 | tr -d '[:space:]' | cut -f2 -d'=' | tr -d '"') + if [[ -z "$addr" ]]; then + log_error "BridgeAddr not found in config.toml" + exit 1 + fi + echo "$addr" +} + +get_sovereign_rollup_addr() { + local addr + addr=$(grep -E '^\s*SovereignRollupAddr\s*=' "$AGGKIT_CONFIG_DIR/config.toml" | head -1 | tr -d '[:space:]' | cut -f2 -d'=' | tr -d '"') + echo "$addr" +} + +get_l1_global_exit_root_address() { + local addr + addr=$(grep -E '^\s*polygonZkEVMGlobalExitRootAddress\s*=' "$AGGKIT_CONFIG_DIR/config.toml" | head -1 | tr -d '[:space:]' | cut -f2 -d'=' | tr -d '"') + echo "$addr" +} + +# --------------------------------------------------------------------------- +# Signer / keystore helpers +# --------------------------------------------------------------------------- + +# Reads the AggSenderPrivateKey password from config.toml. +get_signer_password() { + # AggSenderPrivateKey = {Path = "...", Password = "pSnv6Dh5s9ahuzGzH9RoCDrKAMddaX3m"} + local password + password=$(grep 'AggSenderPrivateKey' "$AGGKIT_CONFIG_DIR/config.toml" \ + | sed -E 's/.*Password = "([^"]+)".*/\1/') + echo "$password" +} + +# Downloads the sequencer keystore file from the kurtosis artifact and writes +# it to OUTPUT_KEYSTORE_PATH. Returns 1 if not available (signer skipped). +get_sequencer_keystore() { + local dest="$1" + local tmp_dir + tmp_dir=$(mktemp -d) + # shellcheck disable=SC2064 + trap "rm -rf '$tmp_dir'" RETURN + + if ! kurtosis files download "$KURTOSIS_ENCLAVE" "$KURTOSIS_ARTIFACT_SEQUENCER_KEYSTORE" "$tmp_dir" &>/dev/null; then + log_warn "Artifact '$KURTOSIS_ARTIFACT_SEQUENCER_KEYSTORE' not found — signerConfig will be omitted" + return 1 + fi + + local keystore_file + keystore_file=$(find "$tmp_dir" -maxdepth 1 -name "*.keystore" 2>/dev/null | head -1) + if [[ -z "$keystore_file" ]]; then + log_warn "No *.keystore file found in artifact '$KURTOSIS_ARTIFACT_SEQUENCER_KEYSTORE' — signerConfig will be omitted" + return 1 + fi + + cp "$keystore_file" "$dest" + chmod 600 "$dest" +} + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +log_info "Enclave: $KURTOSIS_ENCLAVE" +log_info "Network: $NETWORK_SUFFIX (l2NetworkId: $NETWORK_INDEX)" +log_info "L1 service: $L1_SERVICE" +log_info "L2 service: $L2_SERVICE" +log_info "Output: $OUTPUT_PATH" + +log_info "Getting L1 RPC URL..." +L1_RPC_URL=$(get_l1_rpc_url) +log_info "L1 RPC URL: $L1_RPC_URL" + +log_info "Getting L2 RPC URL..." +L2_RPC_URL=$(get_l2_rpc_url) +log_info "L2 RPC URL: $L2_RPC_URL" + +log_info "Downloading aggkit config artifact..." +download_aggkit_config +trap cleanup_aggkit_config EXIT + +log_info "Getting bridge address from aggkit config artifact..." +BRIDGE_ADDR=$(get_bridge_address) +log_info "Bridge address: $BRIDGE_ADDR" + +log_info "Getting sovereign rollup address from aggkit config artifact..." +SOVEREIGN_ROLLUP_ADDR=$(get_sovereign_rollup_addr) +if [[ -n "$SOVEREIGN_ROLLUP_ADDR" ]]; then + log_info "SovereignRollupAddr: $SOVEREIGN_ROLLUP_ADDR" +else + log_warn "SovereignRollupAddr not found in config.toml — threshold check will be skipped at sign time" +fi + +log_info "Getting L1 GlobalExitRoot address from aggkit config artifact..." +L1_GLOBAL_EXIT_ROOT_ADDR=$(get_l1_global_exit_root_address) +if [[ -n "$L1_GLOBAL_EXIT_ROOT_ADDR" ]]; then + log_info "L1GlobalExitRootAddress: $L1_GLOBAL_EXIT_ROOT_ADDR" +else + log_warn "polygonZkEVMGlobalExitRootAddress not found in config.toml — l1GlobalExitRootAddress will be omitted (Step I will fail)" +fi + +log_info "Getting agglayer URLs..." +AGGLAYER_ADMIN_URL=$(get_agglayer_admin_url) +AGGLAYER_GRPC_URL=$(get_agglayer_grpc_url) +log_info "Agglayer admin URL: $AGGLAYER_ADMIN_URL" +log_info "Agglayer gRPC URL: $AGGLAYER_GRPC_URL" + +log_info "Getting zkevm bridge service URL (service: $ZKEVM_BRIDGE_SERVICE)..." +ZKEVM_BRIDGE_SERVICE_URL="" +if ZKEVM_BRIDGE_SERVICE_URL=$(get_zkevm_bridge_service_url); then + log_info "zkevm bridge service URL: $ZKEVM_BRIDGE_SERVICE_URL" +else + log_warn "Service '$ZKEVM_BRIDGE_SERVICE' not found in enclave — bridgeServiceURL will be omitted (Step E bridge service check skipped)" +fi + +mkdir -p "$(dirname "$OUTPUT_PATH")" +OUTPUT_DIR="$(dirname "$OUTPUT_PATH")" + +# --------------------------------------------------------------------------- +# Signer config +# --------------------------------------------------------------------------- +SIGNER_CONFIG_BLOCK="" +KEYSTORE_DEST="$OUTPUT_DIR/sequencer.keystore" + +log_info "Getting signer keystore from artifact '$KURTOSIS_ARTIFACT_SEQUENCER_KEYSTORE'..." +if get_sequencer_keystore "$KEYSTORE_DEST"; then + SIGNER_PASSWORD=$(get_signer_password) + if [[ -z "$SIGNER_PASSWORD" ]]; then + log_warn "AggSenderPrivateKey password not found in config.toml — signerConfig will be omitted" + rm -f "$KEYSTORE_DEST" + else + KEYSTORE_RELATIVE="sequencer.keystore" + log_info "Keystore saved to: $KEYSTORE_DEST" + log_info "Signer password: (extracted from config.toml)" + # Include trailing newline so the heredoc renders cleanly when the block is present + SIGNER_CONFIG_BLOCK=" \"signerConfig\": { + \"Method\": \"local\", + \"Path\": \"$KEYSTORE_RELATIVE\", + \"Password\": \"$SIGNER_PASSWORD\" + }, +" + fi +fi + +SOVEREIGN_ROLLUP_LINE="" +if [[ -n "$SOVEREIGN_ROLLUP_ADDR" ]]; then + SOVEREIGN_ROLLUP_LINE=" \"sovereignRollupAddr\": \"$SOVEREIGN_ROLLUP_ADDR\", +" +fi + +L1_GLOBAL_EXIT_ROOT_LINE="" +if [[ -n "$L1_GLOBAL_EXIT_ROOT_ADDR" ]]; then + L1_GLOBAL_EXIT_ROOT_LINE=" \"l1GlobalExitRootAddress\": \"$L1_GLOBAL_EXIT_ROOT_ADDR\", +" +fi + +BRIDGE_SERVICE_OPTS="" +if [[ -n "$ZKEVM_BRIDGE_SERVICE_URL" ]]; then + BRIDGE_SERVICE_OPTS=" \"bridgeServiceURL\": \"$ZKEVM_BRIDGE_SERVICE_URL\", + \"bridgeServiceType\": \"zkevm\"" +fi + +cat > "$OUTPUT_PATH" < "$launch_file" </dev/null; then + log_warn "python3 not found — add the following entry manually to .vscode/launch.json:" + cat >&2 <&2; } +log_warn() { echo -e "${ORANGE}[WARN]${NC} $*" >&2; } +log_error() { echo -e "${RED}[ERROR]${NC} $*" >&2; } +log_section() { echo -e "\n${BLUE}══ $* ══${NC}" >&2; } + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +TOOL_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +PROJECT_ROOT="$(cd "$TOOL_DIR/../../.." && pwd)" + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- + +KURTOSIS_ENCLAVE="${KURTOSIS_ENCLAVE:-aggkit}" +L1_SERVICE="${L1_SERVICE:-el-1-geth-lighthouse}" +L2_SERVICE="${L2_SERVICE:-op-el-1-op-geth-op-node-001}" +AGGLAYER_SERVICE="${AGGLAYER_SERVICE:-agglayer}" +NETWORK_INDEX="${NETWORK_INDEX:-1}" + +# Kurtosis L1 faucet key (1_000_000_000 ETH on L1 in local enclaves) +PRIVATE_KEY="${PRIVATE_KEY:-0x04b9f63ecf84210c5366c66d68fa1f5da1fa4f634fad6dfc86178e4d79ff9e59}" + +# exitAddress: receives SC-locked value in the certificate — must NOT hold wTTK +EXIT_ADDRESS="${EXIT_ADDRESS:-0x000000000000000000000000000000000000dEaD}" + +TOKEN_TOTAL_SUPPLY="1000000000000000000000" # 1000 TTK (18 decimals) +BRIDGE_AMOUNT="600000000000000000000" # 600 TTK bridged to L2 +SC_LOCK_AMOUNT="400000000000000000000" # 400 TTK transferred to SC (SC-locked) + +OUTPUT_DIR="${OUTPUT_DIR:-/tmp/sc-locked-reproduce}" + +CLAIM_TIMEOUT="${CLAIM_TIMEOUT:-300}" # seconds to wait for L2 auto-claim + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +check_deps() { + local missing=() + command -v kurtosis &>/dev/null || missing+=("kurtosis") + command -v cast &>/dev/null || missing+=("cast") + command -v forge &>/dev/null || missing+=("forge") + command -v anvil &>/dev/null || missing+=("anvil") + command -v go &>/dev/null || missing+=("go") + if [[ ${#missing[@]} -gt 0 ]]; then + log_error "Missing required tools: ${missing[*]}" + log_error "Install Foundry: https://getfoundry.sh" + exit 1 + fi +} + +port_to_localhost_url() { + local raw_url="$1" + local port + port=$(echo "$raw_url" | sed -E 's|^[a-zA-Z]+://||' | cut -f2 -d':') + echo "http://localhost:${port}" +} + +get_rpc_url() { + local service="$1" port_name="$2" + local raw + raw=$(kurtosis port print "$KURTOSIS_ENCLAVE" "$service" "$port_name" 2>/dev/null) \ + || { log_error "Cannot get port '$port_name' from '$service'"; exit 1; } + port_to_localhost_url "$raw" +} + +wait_for_claim() { + local l2_rpc="$1" l2_bridge="$2" deposit_count="$3" + local timeout_secs="${CLAIM_TIMEOUT}" poll_secs=5 elapsed=0 + + local leaf_idx_hex src_net_hex + leaf_idx_hex=$(printf '%064x' "$deposit_count") + src_net_hex=$(printf '%064x' 0) + local calldata="0xcc461632${leaf_idx_hex}${src_net_hex}" + + log_info "Waiting for auto-claim on L2 (depositCount=$deposit_count)..." + while [[ $elapsed -lt $timeout_secs ]]; do + local result val + result=$(cast call --rpc-url "$l2_rpc" "$l2_bridge" "$calldata" 2>/dev/null || echo "0x") + val=$(cast to-dec "${result:-0x0}" 2>/dev/null || echo "0") + if [[ "$val" != "0" ]]; then + log_info "Deposit claimed on L2 after ${elapsed}s" + return 0 + fi + sleep "$poll_secs" + elapsed=$((elapsed + poll_secs)) + log_info " Waiting for claim... ${elapsed}s / ${timeout_secs}s" + done + log_error "Timed out waiting for claim after ${timeout_secs}s" + exit 1 +} + +extract_deposit_count() { + local tx_hash="$1" l1_rpc="$2" + local bridge_event_topic="0x501781209a1f8899323b96b4ef08b168df93e0a90c673d1e4cce39366cb62f9b" + local receipt data + receipt=$(cast receipt --rpc-url "$l1_rpc" "$tx_hash" --json 2>/dev/null || echo "{}") + data=$(echo "$receipt" | python3 -c " +import sys, json +receipt = json.load(sys.stdin) +topic = '$bridge_event_topic' +for log in receipt.get('logs', []): + if log.get('topics', [None])[0] == topic: + print(log.get('data', '')) + break +" 2>/dev/null || true) + [[ -z "$data" ]] && { log_warn "BridgeEvent log not found"; echo ""; return; } + # depositCount is the 8th 32-byte word (7*64=448 hex chars offset) + local hex_data="${data#0x}" + local deposit_count_hex="${hex_data:448:64}" + python3 -c "print(int('$deposit_count_hex', 16))" 2>/dev/null || echo "" +} + +# --------------------------------------------------------------------------- +# Step 1: Deploy test ERC-20 on L1 +# --------------------------------------------------------------------------- + +deploy_test_erc20() { + local l1_rpc="$1" + log_section "Step 1: Deploy test ERC-20 on L1" + + # Write minimal ERC-20 Solidity source + local sol_dir="$OUTPUT_DIR/contracts" + mkdir -p "$sol_dir" + cat > "$sol_dir/TestToken.sol" <<'EOF' +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; +contract TestToken { + string public name = "TestToken"; + string public symbol = "TTK"; + uint8 public decimals = 18; + uint256 public totalSupply; + mapping(address => uint256) public balanceOf; + mapping(address => mapping(address => uint256)) public allowance; + event Transfer(address indexed from, address indexed to, uint256 value); + event Approval(address indexed owner, address indexed spender, uint256 value); + constructor(uint256 _supply) { + totalSupply = _supply; + balanceOf[msg.sender] = _supply; + emit Transfer(address(0), msg.sender, _supply); + } + function transfer(address to, uint256 amount) external returns (bool) { + balanceOf[msg.sender] -= amount; + balanceOf[to] += amount; + emit Transfer(msg.sender, to, amount); + return true; + } + function approve(address spender, uint256 amount) external returns (bool) { + allowance[msg.sender][spender] = amount; + emit Approval(msg.sender, spender, amount); + return true; + } + function transferFrom(address from, address to, uint256 amount) external returns (bool) { + allowance[from][msg.sender] -= amount; + balanceOf[from] -= amount; + balanceOf[to] += amount; + emit Transfer(from, to, amount); + return true; + } +} +EOF + + log_info "Deploying TestToken on L1 (supply: $TOKEN_TOTAL_SUPPLY)..." + local out + out=$(forge create \ + --rpc-url "$l1_rpc" \ + --private-key "$PRIVATE_KEY" \ + --broadcast \ + "$sol_dir/TestToken.sol:TestToken" \ + --constructor-args "$TOKEN_TOTAL_SUPPLY" \ + 2>&1) + echo "$out" >&2 + + local l1_token_addr + l1_token_addr=$(echo "$out" | grep "Deployed to:" | awk '{print $3}') + if [[ -z "$l1_token_addr" ]]; then + log_error "Failed to extract deployed address from forge output" + exit 1 + fi + log_info "TestToken deployed at: $l1_token_addr" + echo "$l1_token_addr" +} + +# --------------------------------------------------------------------------- +# Step 2: Bridge TTK from L1 to L2 +# --------------------------------------------------------------------------- + +bridge_erc20_to_l2() { + local l1_rpc="$1" l2_rpc="$2" l1_bridge="$3" l1_token="$4" recipient="$5" + log_section "Step 2: Bridge $BRIDGE_AMOUNT TTK from L1 to L2" + + log_info "Approving L1 bridge to spend $BRIDGE_AMOUNT TTK..." + cast send \ + --rpc-url "$l1_rpc" \ + --private-key "$PRIVATE_KEY" \ + "$l1_token" \ + "approve(address,uint256)" \ + "$l1_bridge" \ + "$BRIDGE_AMOUNT" >/dev/null + log_info "Approval done." + + log_info "Calling bridgeAsset on L1 bridge..." + local tx_json + tx_json=$(cast send \ + --rpc-url "$l1_rpc" \ + --private-key "$PRIVATE_KEY" \ + --json \ + "$l1_bridge" \ + "bridgeAsset(uint32,address,uint256,address,bool,bytes)" \ + "$NETWORK_INDEX" \ + "$recipient" \ + "$BRIDGE_AMOUNT" \ + "$l1_token" \ + true \ + "0x") + local tx_hash + tx_hash=$(echo "$tx_json" | python3 -c "import sys,json; print(json.load(sys.stdin)['transactionHash'])") + log_info "Bridge tx: $tx_hash" + + local deposit_count + deposit_count=$(extract_deposit_count "$tx_hash" "$l1_rpc") + if [[ -z "$deposit_count" ]]; then + log_error "Could not extract depositCount from bridge tx — cannot wait for claim" + exit 1 + fi + log_info "depositCount: $deposit_count" + + wait_for_claim "$l2_rpc" "$l1_bridge" "$deposit_count" + echo "$deposit_count" +} + +# --------------------------------------------------------------------------- +# Step 3: Find wrapped token address on L2 +# --------------------------------------------------------------------------- + +find_wrapped_token_on_l2() { + local l2_rpc="$1" l2_bridge="$2" l1_network_id="$3" l1_token="$4" + log_section "Step 3: Find wrapped token address on L2" + + # getTokenWrappedAddress(uint32 originNetwork, address originTokenAddress) returns (address) + local calldata + calldata=$(cast calldata "getTokenWrappedAddress(uint32,address)" "$l1_network_id" "$l1_token") + local result + result=$(cast call --rpc-url "$l2_rpc" "$l2_bridge" "$calldata") + local wrapped + wrapped=$(cast parse-bytes32-address "$result" 2>/dev/null || echo "") + if [[ -z "$wrapped" ]] || [[ "$wrapped" == "0x0000000000000000000000000000000000000000" ]]; then + # fallback: decode as address + wrapped=$(cast abi-decode "f()(address)" "$result" 2>/dev/null | head -1 || echo "") + fi + if [[ -z "$wrapped" ]] || [[ "$wrapped" == "0x0000000000000000000000000000000000000000" ]]; then + log_error "Wrapped token not found on L2 for L1 token $l1_token (network $l1_network_id)" + exit 1 + fi + log_info "Wrapped token on L2: $wrapped" + echo "$wrapped" +} + +# --------------------------------------------------------------------------- +# Step 4: Deploy dummy SC holder on L2 and transfer tokens +# --------------------------------------------------------------------------- + +create_sc_locked_tokens() { + local l2_rpc="$1" l2_bridge="$2" wrapped_token="$3" sender="$4" + log_section "Step 4: Create SC-locked tokens on L2" + + # Fund sender on L2 first (bridge ETH for gas) + log_info "Checking L2 ETH balance for gas..." + local l2_bal + l2_bal=$(cast balance --rpc-url "$l2_rpc" "$sender") + if python3 -c "import sys; sys.exit(0 if int('$l2_bal') < 10**15 else 1)" 2>/dev/null; then + log_info "L2 balance low ($l2_bal), bridging ETH for gas..." + local gas_amount="100000000000000000" # 0.1 ETH + cast send \ + --rpc-url "$(get_rpc_url "$L1_SERVICE" rpc)" \ + --private-key "$PRIVATE_KEY" \ + --value "$gas_amount" \ + "$l2_bridge" \ + "bridgeAsset(uint32,address,uint256,address,bool,bytes)" \ + "$NETWORK_INDEX" \ + "$sender" \ + "$gas_amount" \ + "0x0000000000000000000000000000000000000000" \ + true \ + "0x" >/dev/null + log_info "ETH bridge submitted. Waiting for L2 balance..." + local elapsed=0 + while [[ $elapsed -lt 120 ]]; do + l2_bal=$(cast balance --rpc-url "$l2_rpc" "$sender") + python3 -c "import sys; sys.exit(1 if int('$l2_bal') < 10**15 else 0)" 2>/dev/null && break + sleep 5; elapsed=$((elapsed + 5)) + done + log_info "L2 ETH balance: $(cast from-wei "$l2_bal" ether) ETH" + else + log_info "L2 ETH balance: $(cast from-wei "$l2_bal" ether) ETH (sufficient)" + fi + + # Deploy a Solidity holder contract (must have runtime bytecode so eth_getCode != 0x) + log_info "Deploying SC holder on L2 (Solidity contract with runtime code)..." + local sol_dir="$OUTPUT_DIR/contracts" + mkdir -p "$sol_dir" + cat > "$sol_dir/TokenHolder.sol" <<'EOF' +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; +// A non-EOA address that holds ERC-20 tokens. +// Empty Solidity contracts still have non-empty runtime bytecode (compiler metadata hash). +contract TokenHolder {} +EOF + local holder_out + holder_out=$(forge create \ + --rpc-url "$l2_rpc" \ + --private-key "$PRIVATE_KEY" \ + --broadcast \ + "$sol_dir/TokenHolder.sol:TokenHolder" 2>&1) + echo "$holder_out" >&2 + local holder_addr + holder_addr=$(echo "$holder_out" | grep "Deployed to:" | awk '{print $3}') + if [[ -z "$holder_addr" ]]; then + log_error "Failed to deploy holder contract" + exit 1 + fi + # Verify it has code (essential — otherwise Step B classifies it as EOA) + local holder_code + holder_code=$(cast code --rpc-url "$l2_rpc" "$holder_addr" 2>/dev/null || echo "0x") + if [[ "$holder_code" == "0x" ]]; then + log_error "Holder contract at $holder_addr has no code — Step B will treat it as EOA" + exit 1 + fi + log_info "SC holder deployed at: $holder_addr (code length: ${#holder_code} bytes hex)" + + # Check wTTK balance on L2 (use raw hex → decimal to avoid cast annotation like "[2e20]") + balanceof_hex() { cast call --rpc-url "$l2_rpc" "$wrapped_token" "balanceOf(address)" "$1" 2>/dev/null; } + local sender_bal_hex + sender_bal_hex=$(balanceof_hex "$sender") + local sender_wttk + sender_wttk=$(cast to-dec "$sender_bal_hex") + log_info "wTTK balance of sender: $sender_wttk" + + # Determine actual transfer amount: min(SC_LOCK_AMOUNT, sender_balance) + local transfer_amount + transfer_amount=$(python3 -c "print(min(int('$SC_LOCK_AMOUNT'), int('$sender_wttk')))") + log_info "Transferring $transfer_amount wTTK to SC holder $holder_addr..." + cast send \ + --rpc-url "$l2_rpc" \ + --private-key "$PRIVATE_KEY" \ + "$wrapped_token" \ + "transfer(address,uint256)" \ + "$holder_addr" \ + "$transfer_amount" >/dev/null + log_info "Transfer done." + + local eoa_bal sc_bal + eoa_bal=$(cast to-dec "$(balanceof_hex "$sender")") + sc_bal=$(cast to-dec "$(balanceof_hex "$holder_addr")") + log_info "wTTK balance — EOA: $eoa_bal | SC holder: $sc_bal" + log_info "SC-locked amount: $sc_bal (will trigger ensureERC20Balance error in Step G)" + + echo "$holder_addr" +} + +# --------------------------------------------------------------------------- +# Step 5: Build the exit-certificate tool +# --------------------------------------------------------------------------- + +build_tool() { + log_section "Step 5: Build exit-certificate tool" + pushd "$TOOL_DIR" >/dev/null + go build -o "$OUTPUT_DIR/exit-certificate" ./cmd + popd >/dev/null + log_info "Binary: $OUTPUT_DIR/exit-certificate" +} + +# --------------------------------------------------------------------------- +# Step 6: Generate config and run pipeline +# --------------------------------------------------------------------------- + +run_pipeline() { + local l1_rpc="$1" l2_rpc="$2" l2_bridge="$3" l1_network_id="$4" target_block="$5" l1_token="$6" + log_section "Step 6: Run exit-certificate pipeline" + + local agglayer_grpc sovereign_rollup l1_ger_addr + agglayer_grpc=$(get_rpc_url_grpc "$AGGLAYER_SERVICE") + + local config_tmp + config_tmp=$(mktemp -d) + trap "rm -rf '$config_tmp'" RETURN + kurtosis files download "$KURTOSIS_ENCLAVE" "aggkit-bridge-config-001" "$config_tmp" &>/dev/null || true + sovereign_rollup=$(grep -E '^\s*SovereignRollupAddr\s*=' "$config_tmp/config.toml" 2>/dev/null \ + | head -1 | tr -d '[:space:]' | cut -f2 -d'=' | tr -d '"' || echo "") + l1_ger_addr=$(grep -E '^\s*polygonZkEVMGlobalExitRootAddress\s*=' "$config_tmp/config.toml" 2>/dev/null \ + | head -1 | tr -d '[:space:]' | cut -f2 -d'=' | tr -d '"' || echo "") + + local config_file="$OUTPUT_DIR/parameters.json" + cat > "$config_file" <&1 | tee -a "$OUTPUT_DIR/tool-output.log" + "$OUTPUT_DIR/exit-certificate" --config "$config_file" --step a --verbose 2>&1 | tee -a "$OUTPUT_DIR/tool-output.log" + "$OUTPUT_DIR/exit-certificate" --config "$config_file" --step b --verbose 2>&1 | tee -a "$OUTPUT_DIR/tool-output.log" + "$OUTPUT_DIR/exit-certificate" --config "$config_file" --step c --verbose 2>&1 | tee -a "$OUTPUT_DIR/tool-output.log" + "$OUTPUT_DIR/exit-certificate" --config "$config_file" --step d --verbose 2>&1 | tee -a "$OUTPUT_DIR/tool-output.log" + "$OUTPUT_DIR/exit-certificate" --config "$config_file" --step e --verbose 2>&1 | tee -a "$OUTPUT_DIR/tool-output.log" + set -e + + # Show SC-locked values found + if [[ -f "$OUTPUT_DIR/output/step-c-sc-locked-values.json" ]]; then + log_info "SC-locked values (step C output):" + python3 -c " +import json +data = json.load(open('$OUTPUT_DIR/output/step-c-sc-locked-values.json')) +for e in data: + bal = e.get('scLockedBalance', e.get('sc_locked_balance', '0')) + addr = e.get('wrappedTokenAddress', e.get('wrapped_token_address', '?')) + print(f' token={addr} sc_locked={bal}') +" 2>/dev/null || true + fi + + # Phase 2: patch step-e-exit-certificate.json to contain ONLY the SC-locked ERC-20 exit, + # then run step G. This isolates the bug (ensureERC20Balance) from unrelated ETH underflow + # errors caused by genesis balances. + log_info "Phase 2: patching certificate to keep only SC-locked ERC-20 exits..." + local cert_file="$OUTPUT_DIR/output/step-e-exit-certificate.json" + python3 - "$cert_file" "$l1_token" "$EXIT_ADDRESS" <<'PYEOF' +import json, sys, re + +cert_path = sys.argv[1] +l1_token = sys.argv[2].lower() +exit_addr = sys.argv[3].lower() + +with open(cert_path) as f: + cert = json.load(f) + +orig = cert.get('bridge_exits', []) +# Keep only exits where: token matches our L1 TestToken AND destination is exitAddress (SC-locked) +sc_exits = [ + e for e in orig + if (e.get('token_info', {}).get('origin_token_address', '').lower() == l1_token + and e.get('dest_address', '').lower() == exit_addr) +] +print(f" Original exits: {len(orig)}, SC-locked TestToken exits: {len(sc_exits)}", file=sys.stderr) +if not sc_exits: + print(" WARNING: no SC-locked exits found for TestToken — check if step C found SC-locked value > 0", file=sys.stderr) + sys.exit(1) + +cert['bridge_exits'] = sc_exits +with open(cert_path, 'w') as f: + json.dump(cert, f, indent=2) +print(f" Patched certificate has {len(sc_exits)} SC-locked exit(s).", file=sys.stderr) +PYEOF + + # Phase 3: run step G against the patched certificate — expect the ensureERC20Balance error + log_info "Phase 3: running step G — expect ensureERC20Balance error..." + log_info "" + set +e + "$OUTPUT_DIR/exit-certificate" --config "$config_file" --step g --verbose 2>&1 | tee "$OUTPUT_DIR/step-g-output.log" + local exit_code=$? + set -e + + echo "" + if [[ $exit_code -ne 0 ]]; then + log_warn "Step G failed (exit $exit_code) — this is the bug described in F-01" + log_info "" + grep -E "ERC-20 balance insufficient|ensure ERC-20 balance|patching via storage" \ + "$OUTPUT_DIR/step-g-output.log" | head -10 || true + log_info "" + log_info "Root cause (step_g2.go:ensureERC20Balance):" + log_info " The function sees exitAddress has 0 wTTK balance and returns an error." + log_info " It should instead call hardhat_setStorageAt to patch the ERC-20 storage slot." + else + log_info "Step G completed successfully (bug may have been fixed)" + fi +} + +get_rpc_url_grpc() { + local service="$1" + local raw port + raw=$(kurtosis port print "$KURTOSIS_ENCLAVE" "$service" aglr-grpc 2>/dev/null) \ + || { log_error "Cannot get gRPC port from '$service'"; exit 1; } + port=$(echo "$raw" | sed -E 's|^[a-zA-Z]+://||' | cut -f2 -d':') + echo "http://localhost:${port}" +} + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +check_deps + +log_info "Enclave: $KURTOSIS_ENCLAVE" +log_info "Network: $NETWORK_INDEX" +log_info "EXIT_ADDRESS: $EXIT_ADDRESS (receives SC-locked value, must have no wTTK)" +log_info "Output dir: $OUTPUT_DIR" +mkdir -p "$OUTPUT_DIR" + +L1_RPC=$(get_rpc_url "$L1_SERVICE" rpc) +L2_RPC=$(get_rpc_url "$L2_SERVICE" rpc) +log_info "L1 RPC: $L1_RPC" +log_info "L2 RPC: $L2_RPC" + +# Determine L1 network ID (needed to look up wrapped token on L2) +L1_NETWORK_ID=$(cast chain-id --rpc-url "$L1_RPC") +log_info "L1 chainId (used as originNetwork): $L1_NETWORK_ID" + +SENDER=$(cast wallet address --private-key "$PRIVATE_KEY") +log_info "Sender: $SENDER" + +# Get bridge address +BRIDGE_TMP=$(mktemp -d) +kurtosis files download "$KURTOSIS_ENCLAVE" "aggkit-bridge-config-001" "$BRIDGE_TMP" &>/dev/null +L2_BRIDGE=$(grep 'BridgeAddr' "$BRIDGE_TMP/config.toml" | head -1 | tr -d '[:space:]' \ + | cut -f2 -d'=' | tr -d '"') +rm -rf "$BRIDGE_TMP" +log_info "L2 Bridge: $L2_BRIDGE" + +# Step 1: Deploy test ERC-20 on L1 +L1_TOKEN=$(deploy_test_erc20 "$L1_RPC") +log_info "L1 TestToken: $L1_TOKEN" + +# Step 2: Bridge TTK to L2 (sends to SENDER address on L2) +bridge_erc20_to_l2 "$L1_RPC" "$L2_RPC" "$L2_BRIDGE" "$L1_TOKEN" "$SENDER" + +# Step 3: Find wrapped token on L2 +# Note: the bridge uses the L2 networkId as originNetwork for wrapped tokens, not L1 chainId. +# For cross-chain wrapped tokens, originNetwork is the network that originally issued the token. +# In AgglayerBridge, for an L1-originated token bridged to L2, the wrapped token is looked up +# by (originNetwork=0, originTokenAddress=L1_TOKEN) where 0 is the L1 network in the bridge topology. +# But the bridge topology uses networkId(). Let's try network 0 first (typical L1 network in bridge). +WRAPPED_TOKEN="" +for origin_net in 0 1 "$L1_NETWORK_ID"; do + candidate=$(cast call --rpc-url "$L2_RPC" "$L2_BRIDGE" \ + "getTokenWrappedAddress(uint32,address)(address)" \ + "$origin_net" "$L1_TOKEN" 2>/dev/null || echo "0x0000000000000000000000000000000000000000") + if [[ "$candidate" != "0x0000000000000000000000000000000000000000" ]]; then + log_info "Found wrapped token at originNetwork=$origin_net: $candidate" + WRAPPED_TOKEN="$candidate" + break + fi +done +if [[ -z "$WRAPPED_TOKEN" ]]; then + log_error "Could not find wrapped token on L2 for L1 token $L1_TOKEN" + log_error "It may still be pending. Try increasing CLAIM_TIMEOUT." + exit 1 +fi + +# Step 4: Create SC-locked tokens +create_sc_locked_tokens "$L2_RPC" "$L2_BRIDGE" "$WRAPPED_TOKEN" "$SENDER" + +# Capture current L2 block as target +TARGET_BLOCK=$(cast block-number --rpc-url "$L2_RPC") +log_info "Target block (current L2 tip): $TARGET_BLOCK" + +# Step 5: Build the tool +build_tool + +# Step 6: Run and observe the error +run_pipeline "$L1_RPC" "$L2_RPC" "$L2_BRIDGE" "$L1_NETWORK_ID" "$TARGET_BLOCK" "$L1_TOKEN" diff --git a/tools/exit_certificate/step_0.go b/tools/exit_certificate/step_0.go new file mode 100644 index 000000000..55a71ece0 --- /dev/null +++ b/tools/exit_certificate/step_0.go @@ -0,0 +1,596 @@ +package exit_certificate + +import ( + "context" + "encoding/json" + "fmt" + "math/big" + + "github.com/0xPolygon/cdk-contracts-tooling/contracts/aggchain-multisig/agglayerbridgel2" + "github.com/agglayer/aggkit/log" + aggkittypes "github.com/agglayer/aggkit/types" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/ethclient" +) + +// Event topic hashes and function selectors for bridge contract interaction. +var ( + // keccak256("NewWrappedToken(uint32,address,address,bytes)") + newWrappedTokenTopic = common.HexToHash("0x490e59a1701b938786ac72570a1efeac994a3dbe96e2e883e19e902ace6e6a39") + // keccak256("SetSovereignTokenAddress(uint32,address,address,bool)") + // Fires when the bridge manager remaps an origin token to a sovereign ERC-20 address, + // overriding the original wrapped address set by NewWrappedToken. + setSovereignTokenTopic = common.HexToHash("0xdbe8a5da6a7a916d9adfda9160167a0f8a3da415ee6610e810e753853597fce7") +) + +const ( + totalSupplySelector = "0x18160ddd" // totalSupply() + wethTokenSelector = "0xa25927e2" // WETHToken() +) + +// RunStep0 generates the Local Balance Tree (LBT) by scanning the L2 bridge +// for NewWrappedToken events and fetching each token's totalSupply. +// This replaces the external getLBT tool. +func RunStep0(ctx context.Context, cfg *Config) (*Step0Result, error) { + log.Info("═══════════════════════════════════════════") + log.Info(" STEP 0 — Generate LBT (Local Balance Tree)") + log.Info("═══════════════════════════════════════════") + + blockNum, err := resolveTargetBlockNumber(ctx, cfg.L2RPCURL, cfg.TargetBlock) + if err != nil { + return nil, fmt.Errorf("resolve target block: %w", err) + } + if !cfg.TargetBlock.IsConstant() { + log.Infof("Resolved targetBlock=%q → %d", cfg.TargetBlock.String(), blockNum) + } + + rpcURL := cfg.L2RPCURL + bridgeAddr := cfg.L2BridgeAddress + + blockTag := toBlockTag(blockNum) + + log.Infof("Bridge address: %s", bridgeAddr.Hex()) + log.Infof("Block number: %d", blockNum) + + // 1. Scan for NewWrappedToken events + events := fetchNewWrappedTokenEvents(ctx, cfg, blockNum) + log.Infof("Found %d NewWrappedToken events", len(events)) + + // 2. Apply SetSovereignTokenAddress overrides: if the bridge manager remapped an origin + // token to a different ERC-20 after the original NewWrappedToken event, use the sovereign + // address instead. This keeps the LBT's wrapped addresses consistent with what + // getTokenWrappedAddress() returns on the live contract. + events, err = applySovereignTokenOverrides(ctx, cfg, blockNum, events) + if err != nil { + return nil, fmt.Errorf("apply sovereign token overrides: %w", err) + } + + // 3. Fetch totalSupply for each token concurrently + log.Infof("Fetching totalSupply for %d tokens...", len(events)) + entries, err := fetchTotalSupplies( + ctx, rpcURL, events, blockTag, + cfg.Options.RPCBatchSize, cfg.Options.ConcurrencyLimit, + ) + if err != nil { + return nil, fmt.Errorf("fetch total supplies: %w", err) + } + + // 3. Native token unlocked balance + var nativeEntry *LBTEntry + if nativeEntry, err = computeNativeBalance(ctx, rpcURL, bridgeAddr, blockTag); err != nil { + log.Warnf("Failed to compute native balance: %v", err) + } else { + entries = append(entries, *nativeEntry) + log.Infof("Native token unlocked balance: %s", nativeEntry.Balance) + log.Infof("Native token info - OriginNetwork: %d, OriginTokenAddress: %s", + nativeEntry.OriginNetwork, nativeEntry.OriginTokenAddress.Hex()) + } + // 4. WETH token (only on chains with a custom gas token) + if wethEntry, err := fetchWETHBalance(ctx, rpcURL, bridgeAddr, blockTag); err != nil { + log.Infof("No WETH token on this chain (no custom gas token)") + } else if wethEntry != nil { + entries = append(entries, *wethEntry) + log.Infof("WETH token %s balance: %s", wethEntry.WrappedTokenAddress.Hex(), wethEntry.Balance) + } + + log.Infof("STEP 0 complete: %d LBT entries", len(entries)) + return &Step0Result{TargetBlock: blockNum, Entries: entries}, nil +} + +// wrappedTokenEvent holds parsed NewWrappedToken event data. +type wrappedTokenEvent struct { + OriginNetwork uint32 + OriginTokenAddress common.Address + WrappedTokenAddr common.Address + // LegacyAddrs holds previous wrapped addresses replaced by SetSovereignTokenAddress overrides. + LegacyAddrs []common.Address +} + +// fetchNewWrappedTokenEvents scans for NewWrappedToken events via a worker pool. +func fetchNewWrappedTokenEvents(ctx context.Context, cfg *Config, toBlock uint64) []wrappedTokenEvent { + blockRange := cfg.Options.BlockRange + concurrency := cfg.Options.ConcurrencyLimit + + type blockRangeJob struct{ from, to uint64 } + var jobs []blockRangeJob + for start := uint64(0); start <= toBlock; start += uint64(blockRange) { + end := min(start+uint64(blockRange)-1, toBlock) + jobs = append(jobs, blockRangeJob{from: start, to: end}) + } + + log.Infof("Fetching NewWrappedToken events: blocks 0→%d, %d ranges, concurrency=%d", + toBlock, len(jobs), concurrency) + + var allEvents []wrappedTokenEvent + + err := runWorkerPool( + ctx, jobs, concurrency, + func(j blockRangeJob) ([]wrappedTokenEvent, error) { + return fetchWrappedTokenEventsInRange(ctx, cfg.L2RPCURL, cfg.L2BridgeAddress, j.from, j.to) + }, + func(events []wrappedTokenEvent) { + allEvents = append(allEvents, events...) + }, + "NewWrappedToken", + ) + if err != nil { + log.Warnf("Some NewWrappedToken queries failed: %v", err) + } + + return allEvents +} + +// fetchEventLogsInRange calls eth_getLogs for one topic on bridgeAddr and returns the raw data hex strings. +func fetchEventLogsInRange( + ctx context.Context, rpcURL string, bridgeAddr common.Address, + topic common.Hash, fromBlock, toBlock uint64, +) ([]string, error) { + result, err := singleRPC(ctx, rpcURL, "eth_getLogs", []any{ + map[string]any{ + "address": bridgeAddr.Hex(), + "topics": []string{topic.Hex()}, + "fromBlock": toBlockTag(fromBlock), + "toBlock": toBlockTag(toBlock), + }, + }, defaultRetries) + if err != nil { + return nil, err + } + var logs []struct { + Data string `json:"data"` + } + if err := json.Unmarshal(result, &logs); err != nil { + return nil, fmt.Errorf("unmarshal logs: %w", err) + } + data := make([]string, len(logs)) + for i, lg := range logs { + data[i] = lg.Data + } + return data, nil +} + +// fetchWrappedTokenEventsInRange fetches NewWrappedToken logs in a single block range. +func fetchWrappedTokenEventsInRange( + ctx context.Context, rpcURL string, bridgeAddr common.Address, + fromBlock, toBlock uint64, +) ([]wrappedTokenEvent, error) { + logData, err := fetchEventLogsInRange(ctx, rpcURL, bridgeAddr, newWrappedTokenTopic, fromBlock, toBlock) + if err != nil { + return nil, err + } + events := make([]wrappedTokenEvent, 0, len(logData)) + for _, data := range logData { + ev, err := decodeNewWrappedTokenEvent(data) + if err != nil { + log.Warnf("Failed to decode NewWrappedToken event: %v", err) + continue + } + events = append(events, ev) + } + return events, nil +} + +// applySovereignTokenOverrides scans SetSovereignTokenAddress events and updates the wrapped +// token address for any origin tokens that have been remapped by the bridge manager. +// When setSovereignTokenAddress is called on the bridge, getTokenWrappedAddress returns the +// sovereign address instead of the original wrapped one, so the LBT must reflect the same. +func applySovereignTokenOverrides( + ctx context.Context, cfg *Config, toBlock uint64, events []wrappedTokenEvent, +) ([]wrappedTokenEvent, error) { + overrides, err := fetchSetSovereignTokenEvents(ctx, cfg, toBlock) + if err != nil { + return nil, fmt.Errorf("fetch SetSovereignTokenAddress events: %w", err) + } + if len(overrides) == 0 { + return events, nil + } + + // Build override map: (originNetwork, originToken) → sovereignAddr + type originKey struct { + network uint32 + addr common.Address + } + overrideMap := make(map[originKey]common.Address, len(overrides)) + for _, ov := range overrides { + if ov.SovereignAddr != (common.Address{}) { + overrideMap[originKey{ov.OriginNetwork, ov.OriginTokenAddress}] = ov.SovereignAddr + } + } + + // Track which origin tokens we've seen so we can add new entries for tokens that only + // appear in SetSovereignTokenAddress (no prior NewWrappedToken event). + seen := make(map[originKey]bool, len(events)) + result := make([]wrappedTokenEvent, 0, len(events)) + for _, ev := range events { + k := originKey{ev.OriginNetwork, ev.OriginTokenAddress} + seen[k] = true + if sovereign, ok := overrideMap[k]; ok { + log.Infof("SetSovereignTokenAddress override for origin(network=%d addr=%s): %s → %s", + ev.OriginNetwork, ev.OriginTokenAddress.Hex(), ev.WrappedTokenAddr.Hex(), sovereign.Hex()) + ev.LegacyAddrs = append(ev.LegacyAddrs, ev.WrappedTokenAddr) + ev.WrappedTokenAddr = sovereign + } + result = append(result, ev) + } + + // Add entries for sovereign tokens without a prior NewWrappedToken event. + for _, ov := range overrides { + k := originKey{ov.OriginNetwork, ov.OriginTokenAddress} + if !seen[k] && ov.SovereignAddr != (common.Address{}) { + log.Infof("SetSovereignTokenAddress new entry: origin(network=%d addr=%s) → %s", + ov.OriginNetwork, ov.OriginTokenAddress.Hex(), ov.SovereignAddr.Hex()) + result = append(result, wrappedTokenEvent{ + OriginNetwork: ov.OriginNetwork, + OriginTokenAddress: ov.OriginTokenAddress, + WrappedTokenAddr: ov.SovereignAddr, + }) + seen[k] = true + } + } + + return result, nil +} + +// sovereignTokenOverride holds data decoded from a SetSovereignTokenAddress event. +type sovereignTokenOverride struct { + OriginNetwork uint32 + OriginTokenAddress common.Address + SovereignAddr common.Address +} + +// fetchSetSovereignTokenEvents scans for SetSovereignTokenAddress events via a worker pool. +func fetchSetSovereignTokenEvents(ctx context.Context, cfg *Config, toBlock uint64) ([]sovereignTokenOverride, error) { + blockRange := cfg.Options.BlockRange + concurrency := cfg.Options.ConcurrencyLimit + + type blockRangeJob struct{ from, to uint64 } + var jobs []blockRangeJob + for start := uint64(0); start <= toBlock; start += uint64(blockRange) { + end := min(start+uint64(blockRange)-1, toBlock) + jobs = append(jobs, blockRangeJob{from: start, to: end}) + } + + var allOverrides []sovereignTokenOverride + if err := runWorkerPool( + ctx, jobs, concurrency, + func(j blockRangeJob) ([]sovereignTokenOverride, error) { + return fetchSetSovereignTokenEventsInRange(ctx, cfg.L2RPCURL, cfg.L2BridgeAddress, j.from, j.to) + }, + func(ovs []sovereignTokenOverride) { + allOverrides = append(allOverrides, ovs...) + }, + "SetSovereignTokenAddress", + ); err != nil { + return nil, err + } + + log.Infof("Found %d SetSovereignTokenAddress overrides", len(allOverrides)) + return allOverrides, nil +} + +// fetchSetSovereignTokenEventsInRange fetches SetSovereignTokenAddress logs in a single block range. +func fetchSetSovereignTokenEventsInRange( + ctx context.Context, rpcURL string, bridgeAddr common.Address, + fromBlock, toBlock uint64, +) ([]sovereignTokenOverride, error) { + logData, err := fetchEventLogsInRange(ctx, rpcURL, bridgeAddr, setSovereignTokenTopic, fromBlock, toBlock) + if err != nil { + return nil, err + } + overrides := make([]sovereignTokenOverride, 0, len(logData)) + for _, data := range logData { + ov, err := decodeSetSovereignTokenEvent(data) + if err != nil { + log.Warnf("Failed to decode SetSovereignTokenAddress event: %v", err) + continue + } + overrides = append(overrides, ov) + } + return overrides, nil +} + +// decodeSetSovereignTokenEvent decodes ABI-encoded SetSovereignTokenAddress event data. +// Layout: originNetwork(uint32) | originTokenAddress(address) | sovereignTokenAddress(address) | isNotMintable(bool) +func decodeSetSovereignTokenEvent(dataHex string) (sovereignTokenOverride, error) { + data := common.FromHex(dataHex) + const minDataLen = 96 + if len(data) < minDataLen { + return sovereignTokenOverride{}, fmt.Errorf("data too short: %d bytes", len(data)) + } + + originNetwork, err := safeUint32(new(big.Int).SetBytes(data[0:32])) + if err != nil { + return sovereignTokenOverride{}, fmt.Errorf("originNetwork: %w", err) + } + + return sovereignTokenOverride{ + OriginNetwork: originNetwork, + OriginTokenAddress: common.BytesToAddress(data[32:64]), + SovereignAddr: common.BytesToAddress(data[64:96]), + }, nil +} + +// decodeNewWrappedTokenEvent decodes ABI-encoded NewWrappedToken event data. +// Layout: originNetwork(uint32) | originTokenAddress(address) | wrappedTokenAddress(address) | metadata(bytes) +func decodeNewWrappedTokenEvent(dataHex string) (wrappedTokenEvent, error) { + data := common.FromHex(dataHex) + const minDataLen = 96 + if len(data) < minDataLen { + return wrappedTokenEvent{}, fmt.Errorf("data too short: %d bytes", len(data)) + } + + originNetwork, err := safeUint32(new(big.Int).SetBytes(data[0:32])) + if err != nil { + return wrappedTokenEvent{}, fmt.Errorf("originNetwork: %w", err) + } + + return wrappedTokenEvent{ + OriginNetwork: originNetwork, + OriginTokenAddress: common.BytesToAddress(data[32:64]), + WrappedTokenAddr: common.BytesToAddress(data[64:96]), + }, nil +} + +// fetchTotalSupplies queries totalSupply() for each token via concurrentBatchRPC. +// For events that have LegacyAddrs (replaced by SetSovereignTokenAddress), it also fetches +// totalSupply for each legacy address and populates LBTEntry.LegacyTokens. +func fetchTotalSupplies( + ctx context.Context, rpcURL string, + events []wrappedTokenEvent, blockTag string, + rpcBatchSize, concurrency int, +) ([]LBTEntry, error) { + if len(events) == 0 { + return nil, nil + } + + // Build a flat call list: first all current wrapped addresses, then all legacy ones. + // We record where legacy calls start per event so we can reconstruct the results. + type legacySlice struct{ start, count int } + legacyIndex := make([]legacySlice, len(events)) + calls := make([]RPCCall, 0, len(events)) + for _, ev := range events { + calls = append(calls, RPCCall{ + Method: "eth_call", + Params: []any{ + map[string]string{"to": ev.WrappedTokenAddr.Hex(), "data": totalSupplySelector}, + blockTag, + }, + }) + } + legacyStart := len(calls) + for i, ev := range events { + legacyIndex[i] = legacySlice{start: legacyStart, count: len(ev.LegacyAddrs)} + for _, legacyAddr := range ev.LegacyAddrs { + calls = append(calls, RPCCall{ + Method: "eth_call", + Params: []any{ + map[string]string{"to": legacyAddr.Hex(), "data": totalSupplySelector}, + blockTag, + }, + }) + legacyStart++ + } + } + + batchSize := min(max(len(calls)/concurrency, 1), rpcBatchSize) + results, err := concurrentBatchRPC(ctx, rpcURL, calls, batchSize, concurrency, "L2 RPC/totalSupply") + if err != nil { + return nil, err + } + + entries := make([]LBTEntry, 0, len(events)) + for i, result := range results[:len(events)] { + supply := unmarshalHexBigInt(result) + if supply == nil { + supply = new(big.Int) + } + entry := LBTEntry{ + WrappedTokenAddress: events[i].WrappedTokenAddr, + OriginNetwork: events[i].OriginNetwork, + OriginTokenAddress: events[i].OriginTokenAddress, + Balance: supply.String(), + } + ls := legacyIndex[i] + for j := 0; j < ls.count; j++ { + legacySupply := unmarshalHexBigInt(results[ls.start+j]) + if legacySupply == nil { + legacySupply = new(big.Int) + } + entry.LegacyTokens = append(entry.LegacyTokens, LegacyToken{ + Address: events[i].LegacyAddrs[j], + Balance: legacySupply.String(), + }) + } + entries = append(entries, entry) + } + return entries, nil +} + +// computeNativeBalance computes: balance(bridge, block 0) - balance(bridge, targetBlock). +func computeNativeBalance( + ctx context.Context, rpcURL string, + bridgeAddr common.Address, blockTag string, +) (*LBTEntry, error) { + calls := []RPCCall{ + {Method: "eth_getBalance", Params: []any{bridgeAddr.Hex(), "0x0"}}, + {Method: "eth_getBalance", Params: []any{bridgeAddr.Hex(), blockTag}}, + } + + results, err := batchRPC(ctx, rpcURL, calls, defaultRetries) + if err != nil { + return nil, err + } + + initBalance := unmarshalHexBigInt(results[0]) + if initBalance == nil { + initBalance = new(big.Int) + } + currentBalance := unmarshalHexBigInt(results[1]) + if currentBalance == nil { + currentBalance = new(big.Int) + } + + unlocked := new(big.Int).Sub(initBalance, currentBalance) + if unlocked.Sign() < 0 { + unlocked = new(big.Int) + } + + gasTokenNetwork, gasTokenAddress, err := fetchGasTokenInfo(ctx, rpcURL, bridgeAddr) + if err != nil { + gasTokenNetwork = 0 + gasTokenAddress = common.Address{} + } + + return &LBTEntry{ + WrappedTokenAddress: common.Address{}, + OriginNetwork: gasTokenNetwork, + OriginTokenAddress: gasTokenAddress, + Balance: unlocked.String(), + }, nil +} + +// fetchGasTokenInfo calls gasTokenNetwork() and gasTokenAddress() on the bridge. +func fetchGasTokenInfo( + ctx context.Context, rpcURL string, + bridgeAddr common.Address, +) (uint32, common.Address, error) { + l2Client, err := ethclient.DialContext(ctx, rpcURL) + if err != nil { + msg := fmt.Sprintf("dial L2 RPC (%s): %v", rpcURL, err) + log.Infof("❌ %s", msg) + return 0, common.Address{}, err + } + defer l2Client.Close() + caller, err := agglayerbridgel2.NewAgglayerbridgel2Caller(bridgeAddr, l2Client) + if err != nil { + msg := fmt.Sprintf("create bridge caller (addr=%s): %v", bridgeAddr.Hex(), err) + log.Infof("❌ %s", msg) + return 0, common.Address{}, err + } + gasTokenNetwork, err := caller.GasTokenNetwork(&bind.CallOpts{Context: ctx}) + if err != nil { + msg := fmt.Sprintf("query bridge GasTokenNetwork(): %v", err) + log.Infof("❌ %s", msg) + return 0, common.Address{}, err + } + gasTokenAddr, err := caller.GasTokenAddress(&bind.CallOpts{Context: ctx}) + if err != nil { + msg := fmt.Sprintf("query bridge GasTokenAddress(): %v", err) + log.Infof("❌ %s", msg) + return 0, common.Address{}, err + } + + return gasTokenNetwork, gasTokenAddr, nil +} + +// fetchWETHBalance calls WETHToken() and fetches its totalSupply if non-zero. +func fetchWETHBalance( + ctx context.Context, rpcURL string, + bridgeAddr common.Address, blockTag string, +) (*LBTEntry, error) { + result, err := singleRPC(ctx, rpcURL, "eth_call", []any{ + map[string]string{"to": bridgeAddr.Hex(), "data": wethTokenSelector}, + blockTag, + }, defaultRetries) + if err != nil { + return nil, err + } + + var hex string + if err := json.Unmarshal(result, &hex); err != nil { + return nil, fmt.Errorf("parse WETH address: %w", err) + } + + wethAddr := common.HexToAddress(hex) + if wethAddr == (common.Address{}) { + return nil, nil + } + + supplyResult, err := singleRPC(ctx, rpcURL, "eth_call", []any{ + map[string]string{"to": wethAddr.Hex(), "data": totalSupplySelector}, + blockTag, + }, defaultRetries) + if err != nil { + return nil, fmt.Errorf("fetch WETH totalSupply: %w", err) + } + + supply := unmarshalHexBigInt(supplyResult) + if supply == nil { + supply = new(big.Int) + } + + return &LBTEntry{ + WrappedTokenAddress: wethAddr, + OriginNetwork: 0, + OriginTokenAddress: common.Address{}, + Balance: supply.String(), + }, nil +} + +// resolveTargetBlockNumber resolves a BlockNumberFinality to a concrete block number. +// Constant finalities are returned directly; named finalities (latest, finalized, safe, +// pending) and any configured offset are resolved via the L2 RPC. +func resolveTargetBlockNumber( + ctx context.Context, rpcURL string, finality aggkittypes.BlockNumberFinality, +) (uint64, error) { + if finality.IsConstant() { + return finality.Specific, nil + } + client, err := ethclient.DialContext(ctx, rpcURL) + if err != nil { + return 0, fmt.Errorf("dial L2 RPC: %w", err) + } + defer client.Close() + return finality.BlockNumber(ctx, ðClientAdapter{client}) +} + +// ethClientAdapter wraps *ethclient.Client to satisfy aggkittypes.CustomEthereumClienter +// for the sole purpose of resolving a BlockNumberFinality to a concrete block number. +type ethClientAdapter struct { + *ethclient.Client +} + +func (a *ethClientAdapter) CustomHeaderByNumber( + ctx context.Context, number *aggkittypes.BlockNumberFinality, +) (*aggkittypes.BlockHeader, error) { + bigInt := number.ToBigInt() + if number.HasOffset() { + base, err := a.HeaderByNumber(ctx, number.Block.ToBigInt()) + if err != nil { + return nil, err + } + bigInt = new(big.Int).SetUint64(number.CalculateBlockNumber(base.Number.Uint64())) + } + header, err := a.HeaderByNumber(ctx, bigInt) + if err != nil { + return nil, err + } + return &aggkittypes.BlockHeader{Number: header.Number.Uint64()}, nil +} + +func (a *ethClientAdapter) RetrieveBlockHeaders( + _ context.Context, _ []uint64, _ int, +) (*aggkittypes.BlockHeadersResult, error) { + return nil, fmt.Errorf("not implemented") +} diff --git a/tools/exit_certificate/step_0_rpc_test.go b/tools/exit_certificate/step_0_rpc_test.go new file mode 100644 index 000000000..f90d99eab --- /dev/null +++ b/tools/exit_certificate/step_0_rpc_test.go @@ -0,0 +1,162 @@ +package exit_certificate + +import ( + "context" + "encoding/json" + "math/big" + "strings" + "testing" + + aggkittypes "github.com/agglayer/aggkit/types" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +const rpcMethodEthBlockNumber = "eth_blockNumber" + +// hexWord returns the 32-byte big-endian hex (0x-prefixed) of v, as an eth_call uint return. +func hexWord(v int64) string { + w := make([]byte, 32) + new(big.Int).SetInt64(v).FillBytes(w) + return "0x" + common.Bytes2Hex(w) +} + +// makeWrappedTokenData builds the 96-byte NewWrappedToken event payload. +func makeWrappedTokenData(originNet uint32, originAddr, wrappedAddr common.Address) string { + data := make([]byte, 96) + new(big.Int).SetUint64(uint64(originNet)).FillBytes(data[0:32]) + copy(data[44:64], originAddr.Bytes()) + copy(data[76:96], wrappedAddr.Bytes()) + return "0x" + common.Bytes2Hex(data) +} + +func TestDecodeNewWrappedTokenEvent(t *testing.T) { + t.Parallel() + origin := common.BytesToAddress([]byte("origin")) + wrapped := common.BytesToAddress([]byte("wrapped")) + ev, err := decodeNewWrappedTokenEvent(makeWrappedTokenData(3, origin, wrapped)) + require.NoError(t, err) + require.Equal(t, uint32(3), ev.OriginNetwork) + require.Equal(t, origin, ev.OriginTokenAddress) + require.Equal(t, wrapped, ev.WrappedTokenAddr) + + _, err = decodeNewWrappedTokenEvent("0x1234") // too short + require.Error(t, err) +} + +func TestDecodeSetSovereignTokenEvent(t *testing.T) { + t.Parallel() + origin := common.BytesToAddress([]byte("origin")) + sovereign := common.BytesToAddress([]byte("sovereign")) + ov, err := decodeSetSovereignTokenEvent(makeWrappedTokenData(2, origin, sovereign)) + require.NoError(t, err) + require.Equal(t, uint32(2), ov.OriginNetwork) + require.Equal(t, origin, ov.OriginTokenAddress) + require.Equal(t, sovereign, ov.SovereignAddr) + + _, err = decodeSetSovereignTokenEvent("0xabcd") // too short + require.Error(t, err) +} + +// step0Stub serves every RPC RunStep0 makes: NewWrappedToken/SetSovereignToken log scans, +// totalSupply / WETHToken / gas-token eth_calls and the native-balance eth_getBalance pair. +func step0Stub(t *testing.T, wrappedData string) string { + t.Helper() + gasNetSel := "0x" + selectorHex(bridgeABI, "gasTokenNetwork") + gasAddrSel := "0x" + selectorHex(bridgeABI, "gasTokenAddress") + + return newBatchRPCServer(t, func(method string, params []json.RawMessage) any { + switch method { + case rpcMethodEthBlockNumber: + return "0x64" + case "eth_getLogs": + var f struct { + Topics []string `json:"topics"` + } + _ = json.Unmarshal(params[0], &f) + if len(f.Topics) > 0 && strings.EqualFold(f.Topics[0], newWrappedTokenTopic.Hex()) { + return []map[string]string{{"data": wrappedData}} + } + return []map[string]string{} // SetSovereignTokenAddress: none + case rpcMethodEthCall: + var c struct { + Data string `json:"data"` + Input string `json:"input"` + } + _ = json.Unmarshal(params[0], &c) + d := c.Data + if d == "" { + d = c.Input + } + switch { + case strings.HasPrefix(d, totalSupplySelector): + return hexWord(1000) + case strings.HasPrefix(d, wethTokenSelector): + return hexWord(0) // zero WETH address → no WETH entry + case strings.HasPrefix(d, gasNetSel): + return hexWord(0) + case strings.HasPrefix(d, gasAddrSel): + return hexWord(0) + } + return "0x" + case "eth_getBalance": + var tag string + _ = json.Unmarshal(params[1], &tag) + if tag == "0x0" { + return "0x64" // genesis balance 100 + } + return "0xa" // current balance 10 → unlocked native = 90 + } + return "0x" + }) +} + +func TestRunStep0(t *testing.T) { + t.Parallel() + originToken := common.BytesToAddress([]byte("origin")) + wrappedToken := common.BytesToAddress([]byte("wrapped")) + url := step0Stub(t, makeWrappedTokenData(1, originToken, wrappedToken)) + + cfg := &Config{ + L2RPCURL: url, + L2BridgeAddress: common.BytesToAddress([]byte("bridge")), + TargetBlock: *aggkittypes.NewBlockNumber(100), + Options: Options{BlockRange: 50, ConcurrencyLimit: 2, RPCBatchSize: 10}, + } + + res, err := RunStep0(context.Background(), cfg) + require.NoError(t, err) + require.Equal(t, uint64(100), res.TargetBlock) + + // one wrapped token (supply 1000) + the native entry (unlocked 90); no WETH. + var wrapped, native *LBTEntry + for i := range res.Entries { + e := &res.Entries[i] + switch e.WrappedTokenAddress { + case wrappedToken: + wrapped = e + case common.Address{}: + native = e + } + } + require.NotNil(t, wrapped, "wrapped token entry present") + require.Equal(t, "1000", wrapped.Balance) + require.Equal(t, uint32(1), wrapped.OriginNetwork) + require.NotNil(t, native, "native entry present") + require.Equal(t, "90", native.Balance) +} + +func TestResolveTargetBlockNumberConstant(t *testing.T) { + t.Parallel() + // a constant block number resolves with no RPC call. + n, err := resolveTargetBlockNumber(context.Background(), "", *aggkittypes.NewBlockNumber(4242)) + require.NoError(t, err) + require.Equal(t, uint64(4242), n) +} + +func TestFetchTotalSuppliesEmpty(t *testing.T) { + t.Parallel() + entries, err := fetchTotalSupplies(context.Background(), "", nil, "latest", 10, 2) + require.NoError(t, err) + require.Nil(t, entries) +} diff --git a/tools/exit_certificate/step_0_sovereign_test.go b/tools/exit_certificate/step_0_sovereign_test.go new file mode 100644 index 000000000..b17039762 --- /dev/null +++ b/tools/exit_certificate/step_0_sovereign_test.go @@ -0,0 +1,96 @@ +package exit_certificate + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +// sovereignStub serves eth_getLogs for the SetSovereignTokenAddress scan, returning the given event +// payload for that topic and nothing for any other topic. +func sovereignStub(t *testing.T, sovereignData string) string { + t.Helper() + return newBatchRPCServer(t, func(method string, params []json.RawMessage) any { + if method != rpcMethodEthGetLogs { + return "0x" + } + var f struct { + Topics []string `json:"topics"` + } + _ = json.Unmarshal(params[0], &f) + if len(f.Topics) > 0 && strings.EqualFold(f.Topics[0], setSovereignTokenTopic.Hex()) { + return []map[string]string{{"data": sovereignData}} + } + return []map[string]string{} + }) +} + +func TestApplySovereignTokenOverrides(t *testing.T) { + t.Parallel() + origin := common.BytesToAddress([]byte("origin")) + sovereign := common.BytesToAddress([]byte("sovereign")) + wrapped := common.BytesToAddress([]byte("wrapped")) + + url := sovereignStub(t, makeWrappedTokenData(1, origin, sovereign)) + cfg := &Config{ + L2RPCURL: url, L2BridgeAddress: common.BytesToAddress([]byte("bridge")), + Options: Options{BlockRange: 50, ConcurrencyLimit: 2, RPCBatchSize: 10}, + } + + // A prior NewWrappedToken event for the same origin token gets its wrapped address overridden. + events := []wrappedTokenEvent{ + {OriginNetwork: 1, OriginTokenAddress: origin, WrappedTokenAddr: wrapped}, + } + out, err := applySovereignTokenOverrides(context.Background(), cfg, 100, events) + require.NoError(t, err) + require.Len(t, out, 1) + require.Equal(t, sovereign, out[0].WrappedTokenAddr) + require.Contains(t, out[0].LegacyAddrs, wrapped) +} + +func TestApplySovereignTokenOverridesNewEntry(t *testing.T) { + t.Parallel() + origin := common.BytesToAddress([]byte("origin2")) + sovereign := common.BytesToAddress([]byte("sovereign2")) + + url := sovereignStub(t, makeWrappedTokenData(1, origin, sovereign)) + cfg := &Config{ + L2RPCURL: url, L2BridgeAddress: common.BytesToAddress([]byte("bridge")), + Options: Options{BlockRange: 50, ConcurrencyLimit: 2, RPCBatchSize: 10}, + } + + // No prior NewWrappedToken event → the override is added as a new entry. + out, err := applySovereignTokenOverrides(context.Background(), cfg, 100, nil) + require.NoError(t, err) + require.Len(t, out, 1) + require.Equal(t, sovereign, out[0].WrappedTokenAddr) +} + +func TestApplySovereignTokenOverridesNone(t *testing.T) { + t.Parallel() + url := newBatchRPCServer(t, func(method string, _ []json.RawMessage) any { + if method == rpcMethodEthGetLogs { + return []map[string]string{} // no SetSovereignTokenAddress events + } + return "0x" + }) + cfg := &Config{ + L2RPCURL: url, L2BridgeAddress: common.BytesToAddress([]byte("bridge")), + Options: Options{BlockRange: 50, ConcurrencyLimit: 2, RPCBatchSize: 10}, + } + events := []wrappedTokenEvent{{OriginNetwork: 1, OriginTokenAddress: common.BytesToAddress([]byte("o"))}} + out, err := applySovereignTokenOverrides(context.Background(), cfg, 100, events) + require.NoError(t, err) + require.Equal(t, events, out) +} + +func TestRetrieveBlockHeadersNotImplemented(t *testing.T) { + t.Parallel() + a := ðClientAdapter{} + _, err := a.RetrieveBlockHeaders(context.Background(), []uint64{1}, 1) + require.Error(t, err) +} diff --git a/tools/exit_certificate/step_a.go b/tools/exit_certificate/step_a.go new file mode 100644 index 000000000..0f68dd451 --- /dev/null +++ b/tools/exit_certificate/step_a.go @@ -0,0 +1,401 @@ +package exit_certificate + +import ( + "context" + "encoding/json" + "fmt" + "sort" + "strings" + "sync" + "time" + + "github.com/agglayer/aggkit/log" + "github.com/ethereum/go-ethereum/common" +) + +// RunStepA runs Step A1 followed by Step A2 and returns the combined result. +// Step A1 collects touched addresses via debug_traceTransaction (prestateTracer + diffMode). +// Step A2 recovers additional addresses from tx receipts for any traces that failed in A1. +func RunStepA(ctx context.Context, cfg *Config, targetBlock uint64) (*StepAResult, error) { + a1Result, err := RunStepA1(ctx, cfg, targetBlock) + if err != nil { + return nil, err + } + a2Result, err := RunStepA2(ctx, cfg, a1Result.FailedTraces) + if err != nil { + return nil, err + } + combined := mergeAddresses(a1Result.Addresses, a2Result.Addresses) + log.Infof("STEP A complete: %d addresses (A1: %d, A2 new: %d)", + len(combined), len(a1Result.Addresses), len(combined)-len(a1Result.Addresses)) + return &StepAResult{ + Addresses: combined, + FailedTraces: a1Result.FailedTraces, + }, nil +} + +// RunStepA1 collects all touched addresses from genesis to targetBlock using +// debug_traceTransaction with prestateTracer + diffMode. +// Blocks are scanned in windows of Options.StepAWindowSize to bound peak memory usage: +// at most one window of block headers and their tx hashes are in memory at a time. +func RunStepA1(ctx context.Context, cfg *Config, targetBlock uint64) (*StepAResult, error) { + log.Info("═══════════════════════════════════════════") + log.Info(" STEP A1 — Collect addresses (prestateTracer)") + log.Info("═══════════════════════════════════════════") + + if targetBlock < cfg.Options.L2StartBlock { + return nil, fmt.Errorf("targetBlock %d is before l2StartBlock %d", targetBlock, cfg.Options.L2StartBlock) + } + + windowSize := uint64(cfg.Options.StepAWindowSize) + totalBlocks := targetBlock - cfg.Options.L2StartBlock + 1 + log.Infof("Scanning %d blocks in windows of %d (L2 %d → %d)...", + totalBlocks, windowSize, cfg.Options.L2StartBlock, targetBlock) + + finalAddrs := make(map[common.Address]struct{}) + var allFailed []FailedTrace + stepStart := time.Now() + + for start := cfg.Options.L2StartBlock; start <= targetBlock; start += windowSize { + end := min(start+windowSize-1, targetBlock) + + hashes, err := scanBlockHeaders(ctx, cfg.L2RPCURL, start, end, + cfg.Options.RPCBatchSize, cfg.Options.ConcurrencyLimit) + if err != nil { + return nil, fmt.Errorf("scan blocks [%d-%d]: %w", start, end, err) + } + + if len(hashes) == 0 { + continue + } + + addrs, failed, err := traceTransactions(ctx, cfg.L2RPCURL, hashes, + cfg.Options.ConcurrencyLimit, cfg.Options.IgnoreOnTraceError) + if err != nil { + return nil, fmt.Errorf("trace transactions [%d-%d]: %w", start, end, err) + } + + for _, addr := range addrs { + finalAddrs[addr] = struct{}{} + } + allFailed = append(allFailed, failed...) + + blocksProcessed := end - cfg.Options.L2StartBlock + 1 + elapsed := time.Since(stepStart) + blocksPerSec := float64(blocksProcessed) / elapsed.Seconds() + remaining := targetBlock - end + var eta string + if blocksPerSec > 0 { + eta = (time.Duration(float64(remaining)/blocksPerSec) * time.Second).Round(time.Second).String() + } else { + eta = "—" + } + log.Infof("Progress: %d/%d blocks (%.1f%%) — %.0f blocks/s — ETA %s", + blocksProcessed, totalBlocks, + float64(blocksProcessed)/float64(totalBlocks)*percentMultiplier, + blocksPerSec, eta) + } + + delete(finalAddrs, common.Address{}) + + if len(finalAddrs) == 0 && len(allFailed) == 0 { + log.Info("STEP A1 complete: 0 unique addresses (no transactions found)") + return &StepAResult{}, nil + } + + addresses := make([]common.Address, 0, len(finalAddrs)) + for addr := range finalAddrs { + addresses = append(addresses, addr) + } + sort.Slice(addresses, func(i, j int) bool { + return strings.ToLower(addresses[i].Hex()) < strings.ToLower(addresses[j].Hex()) + }) + + if len(allFailed) > 0 { + log.Warnf("STEP A1 complete: %d unique addresses (%d trace failures — run step A2 to recover)", + len(addresses), len(allFailed)) + } else { + log.Infof("STEP A1 complete: %d unique addresses", len(addresses)) + } + return &StepAResult{Addresses: addresses, FailedTraces: allFailed}, nil +} + +// RunStepA2 recovers addresses from tx receipts for traces that failed in Step A1. +// For each FailedTrace it calls eth_getTransactionReceipt and extracts all addresses +// found in the receipt: sender (from), recipient (to), created contract, and log emitters. +// Failed receipt fetches are logged as warnings and skipped rather than aborting. +func RunStepA2(ctx context.Context, cfg *Config, failedTraces []FailedTrace) (*StepA2Result, error) { + log.Info("═══════════════════════════════════════════") + log.Info(" STEP A2 — Recover addresses from tx receipts") + log.Info("═══════════════════════════════════════════") + + if len(failedTraces) == 0 { + log.Info("STEP A2 complete: no failed traces — nothing to process") + return &StepA2Result{}, nil + } + + log.Infof("Processing %d failed traces via eth_getTransactionReceipt...", len(failedTraces)) + + hashes := make([]common.Hash, len(failedTraces)) + for i, ft := range failedTraces { + hashes[i] = ft.Hash + } + + addrSet := make(map[common.Address]struct{}) + + err := runWorkerPool( + ctx, hashes, cfg.Options.ConcurrencyLimit, + func(hash common.Hash) ([]common.Address, error) { + addrs, fetchErr := receiptAddresses(ctx, cfg.L2RPCURL, hash) + if fetchErr != nil { + log.Warnf("STEP A2: receipt failed for %s (skipping): %v", hash.Hex(), fetchErr) + return nil, nil + } + return addrs, nil + }, + func(addrs []common.Address) { + for _, addr := range addrs { + addrSet[addr] = struct{}{} + } + }, + "Receipts", + ) + if err != nil { + return nil, fmt.Errorf("fetch receipts: %w", err) + } + + delete(addrSet, common.Address{}) + + addresses := make([]common.Address, 0, len(addrSet)) + for addr := range addrSet { + addresses = append(addresses, addr) + } + sort.Slice(addresses, func(i, j int) bool { + return strings.ToLower(addresses[i].Hex()) < strings.ToLower(addresses[j].Hex()) + }) + + log.Infof("STEP A2 complete: %d addresses recovered from %d failed traces", len(addresses), len(failedTraces)) + return &StepA2Result{Addresses: addresses}, nil +} + +// receiptAddresses fetches eth_getTransactionReceipt for hash and returns all addresses +// found in the receipt: sender (from), recipient (to), created contract, and log emitters. +func receiptAddresses(ctx context.Context, rpcURL string, hash common.Hash) ([]common.Address, error) { + result, err := singleRPC(ctx, rpcURL, "eth_getTransactionReceipt", []any{hash.Hex()}, defaultRetries) + if err != nil { + return nil, fmt.Errorf("receipt %s: %w", hash.Hex(), err) + } + + if len(result) == 0 || string(result) == "null" { + return nil, fmt.Errorf("receipt for %s is null", hash.Hex()) + } + + var receipt struct { + From string `json:"from"` + To *string `json:"to"` + ContractAddress *string `json:"contractAddress"` + Logs []struct { + Address string `json:"address"` + } `json:"logs"` + } + if err := json.Unmarshal(result, &receipt); err != nil { + return nil, fmt.Errorf("unmarshal receipt %s: %w", hash.Hex(), err) + } + + addrSet := make(map[common.Address]struct{}) + addHex := func(s string) { + if s == "" || s == "0x" { + return + } + addr := common.HexToAddress(s) + if addr != (common.Address{}) { + addrSet[addr] = struct{}{} + } + } + + addHex(receipt.From) + if receipt.To != nil { + addHex(*receipt.To) + } + if receipt.ContractAddress != nil { + addHex(*receipt.ContractAddress) + } + for _, l := range receipt.Logs { + addHex(l.Address) + } + + addresses := make([]common.Address, 0, len(addrSet)) + for addr := range addrSet { + addresses = append(addresses, addr) + } + return addresses, nil +} + +// mergeAddresses deduplicates and sorts the union of two address slices. +func mergeAddresses(a, b []common.Address) []common.Address { + seen := make(map[common.Address]struct{}, len(a)+len(b)) + for _, addr := range a { + seen[addr] = struct{}{} + } + for _, addr := range b { + seen[addr] = struct{}{} + } + delete(seen, common.Address{}) + + merged := make([]common.Address, 0, len(seen)) + for addr := range seen { + merged = append(merged, addr) + } + sort.Slice(merged, func(i, j int) bool { + return strings.ToLower(merged[i].Hex()) < strings.ToLower(merged[j].Hex()) + }) + return merged +} + +func scanBlockHeaders( + ctx context.Context, rpcURL string, startBlock, targetBlock uint64, batchSize, concurrency int, +) ([]common.Hash, error) { + totalBlocks := targetBlock - startBlock + 1 + log.Infof("Scanning %d blocks [ %d to %d ] for tx hashes (concurrency=%d, batchSize=%d)...", + totalBlocks, startBlock, targetBlock, concurrency, batchSize) + + calls := make([]RPCCall, totalBlocks) + for b := startBlock; b <= targetBlock; b++ { + calls[b-startBlock] = RPCCall{ + Method: "eth_getBlockByNumber", + Params: []any{toBlockTag(b), false}, + } + } + + results, err := concurrentBatchRPC(ctx, rpcURL, calls, batchSize, concurrency, "STEP A: L2 RPC/blockHeaders") + if err != nil { + return nil, fmt.Errorf("scan block headers: %w", err) + } + + var hashes []common.Hash + for _, result := range results { + if result == nil { + continue + } + var block struct { + Transactions []string `json:"transactions"` + } + if err := json.Unmarshal(result, &block); err != nil { + log.Warnf("Failed to unmarshal block header: %v", err) + continue + } + for _, h := range block.Transactions { + hashes = append(hashes, common.HexToHash(h)) + } + } + + log.Infof("Scan complete: %d tx hashes from %d blocks", len(hashes), totalBlocks) + return hashes, nil +} + +// traceTransactions traces all transactions via a worker pool and returns deduplicated addresses. +// When continueOnError is true, failed traces are collected in failedTraces instead of aborting. +// The returned slice is not sorted; callers are responsible for final ordering. +func traceTransactions( + ctx context.Context, rpcURL string, + txHashes []common.Hash, concurrency int, continueOnError bool, +) (addresses []common.Address, failedTraces []FailedTrace, err error) { + totalTx := len(txHashes) + log.Infof("Tracing %d transactions (concurrency=%d)...", totalTx, concurrency) + + // When continueOnError=false we cancel the derived context on the first failure so + // in-flight workers abort their HTTP calls immediately instead of tracing every + // remaining transaction before the error is returned. + traceCtx, cancel := context.WithCancel(ctx) + defer cancel() + + addressSet := make(map[common.Address]struct{}) + var mu sync.Mutex + var failed []FailedTrace + + // firstTraceErr captures the original failure before context.Canceled errors from + // aborted workers arrive — ensuring the caller sees a meaningful error message. + var firstTraceErr error + + poolErr := runWorkerPool( + traceCtx, txHashes, concurrency, + func(hash common.Hash) ([]common.Address, error) { + addrs, traceErr := traceOneTransaction(traceCtx, rpcURL, hash) + if traceErr != nil { + if continueOnError { + mu.Lock() + failed = append(failed, FailedTrace{Hash: hash, Error: traceErr.Error()}) + mu.Unlock() + log.Warnf("Trace failed for %s (skipping): %v", hash.Hex(), traceErr) + return nil, nil + } + log.Errorf("Trace failed for %s : %v", hash.Hex(), traceErr) + mu.Lock() + if firstTraceErr == nil { + firstTraceErr = traceErr + } + mu.Unlock() + cancel() // abort in-flight workers + return addrs, traceErr + } + return addrs, nil + }, + func(addrs []common.Address) { + for _, addr := range addrs { + addressSet[addr] = struct{}{} + } + }, + "Traces", + ) + if poolErr != nil { + if firstTraceErr != nil { + return nil, nil, fmt.Errorf("trace failures: %w", firstTraceErr) + } + return nil, nil, fmt.Errorf("trace failures: %w", poolErr) + } + + log.Infof("Traced %d txs: %d unique addresses", totalTx, len(addressSet)) + + addresses = make([]common.Address, 0, len(addressSet)) + for addr := range addressSet { + addresses = append(addresses, addr) + } + return addresses, failed, nil +} + +// traceOneTransaction traces a single transaction with prestateTracer (diffMode) +// and returns all addresses found in the pre and post state. +func traceOneTransaction(ctx context.Context, rpcURL string, txHash common.Hash) ([]common.Address, error) { + result, err := singleRPC(ctx, rpcURL, "debug_traceTransaction", []any{ + txHash.Hex(), + map[string]any{ + "tracer": "prestateTracer", + "tracerConfig": map[string]any{"diffMode": true}, + }, + }, defaultRetries) + if err != nil { + return nil, fmt.Errorf("trace transaction %s: %w", txHash.Hex(), err) + } + + var trace struct { + Pre map[string]any `json:"pre"` + Post map[string]any `json:"post"` + } + if err := json.Unmarshal(result, &trace); err != nil { + return nil, fmt.Errorf("unmarshal trace for transaction %s: %w", txHash.Hex(), err) + } + + addrSet := make(map[common.Address]struct{}, len(trace.Pre)+len(trace.Post)) + for addr := range trace.Pre { + addrSet[common.HexToAddress(addr)] = struct{}{} + } + for addr := range trace.Post { + addrSet[common.HexToAddress(addr)] = struct{}{} + } + + addresses := make([]common.Address, 0, len(addrSet)) + for addr := range addrSet { + addresses = append(addresses, addr) + } + return addresses, nil +} diff --git a/tools/exit_certificate/step_a_test.go b/tools/exit_certificate/step_a_test.go new file mode 100644 index 000000000..2d5889dfc --- /dev/null +++ b/tools/exit_certificate/step_a_test.go @@ -0,0 +1,302 @@ +package exit_certificate + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +const testAddr1 = "0x1000000000000000000000000000000000000001" + +// newTraceServer returns a test server that responds to debug_traceTransaction. +// The handler receives the tx hash (from params[0]) and returns the result/error +// provided by the given responder function. +func newTraceServer(t *testing.T, responder func(txHex string) jsonRPCResponse) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req struct { + Params []json.RawMessage `json:"params"` + } + require.NoError(t, json.NewDecoder(r.Body).Decode(&req)) + var txHex string + require.NoError(t, json.Unmarshal(req.Params[0], &txHex)) + + resp := responder(txHex) + w.Header().Set("Content-Type", "application/json") + require.NoError(t, json.NewEncoder(w).Encode(resp)) + })) +} + +func TestTraceOneTransaction_Success(t *testing.T) { + t.Parallel() + + addr1 := testAddr1 + addr2 := "0x2000000000000000000000000000000000000002" + addr3 := "0x3000000000000000000000000000000000000003" + + server := newTraceServer(t, func(_ string) jsonRPCResponse { + return jsonRPCResponse{ + JSONRPC: "2.0", + ID: 1, + Result: json.RawMessage(`{"pre":{"` + addr1 + `":{},"` + addr2 + `":{}},"post":{"` + addr3 + `":{}}}`), + } + }) + defer server.Close() + + addrs, err := traceOneTransaction(context.Background(), server.URL, common.HexToHash("0xabc")) + require.NoError(t, err) + require.Len(t, addrs, 3) + + addrSet := make(map[common.Address]struct{}, len(addrs)) + for _, a := range addrs { + addrSet[a] = struct{}{} + } + require.Contains(t, addrSet, common.HexToAddress(addr1)) + require.Contains(t, addrSet, common.HexToAddress(addr2)) + require.Contains(t, addrSet, common.HexToAddress(addr3)) +} + +// An address that appears in both pre and post must be deduplicated. +func TestTraceOneTransaction_DeduplicatesPreAndPost(t *testing.T) { + t.Parallel() + + addr := testAddr1 + + server := newTraceServer(t, func(_ string) jsonRPCResponse { + return jsonRPCResponse{ + JSONRPC: "2.0", + ID: 1, + Result: json.RawMessage(`{"pre":{"` + addr + `":{}},"post":{"` + addr + `":{}}}`), + } + }) + defer server.Close() + + addrs, err := traceOneTransaction(context.Background(), server.URL, common.HexToHash("0xabc")) + require.NoError(t, err) + require.Len(t, addrs, 1) + require.Equal(t, common.HexToAddress(addr), addrs[0]) +} + +// An RPC-level error must be wrapped with the tx hash and propagated. +func TestTraceOneTransaction_RPCError(t *testing.T) { + t.Parallel() + + server := newTraceServer(t, func(_ string) jsonRPCResponse { + return jsonRPCResponse{ + JSONRPC: "2.0", + ID: 1, + Error: &jsonRPCError{Code: -32000, Message: "transaction not found"}, + } + }) + defer server.Close() + + txHash := common.HexToHash("0xdeadbeef") + _, err := traceOneTransaction(context.Background(), server.URL, txHash) + require.Error(t, err) + require.ErrorContains(t, err, "transaction not found") + require.ErrorContains(t, err, txHash.Hex()) +} + +// A valid RPC response whose result can't be decoded as a trace must return an unmarshal error. +func TestTraceOneTransaction_BadJSONResult(t *testing.T) { + t.Parallel() + + server := newTraceServer(t, func(_ string) jsonRPCResponse { + return jsonRPCResponse{ + JSONRPC: "2.0", + ID: 1, + Result: json.RawMessage(`"not-an-object"`), + } + }) + defer server.Close() + + txHash := common.HexToHash("0xbadf00d") + _, err := traceOneTransaction(context.Background(), server.URL, txHash) + require.Error(t, err) + require.ErrorContains(t, err, "unmarshal trace") +} + +// A response with result:null alongside an error field must still propagate the error +// (some nodes return both fields simultaneously when the handler crashes). +func TestTraceOneTransaction_NullResultWithError(t *testing.T) { + t.Parallel() + + server := newTraceServer(t, func(_ string) jsonRPCResponse { + return jsonRPCResponse{ + JSONRPC: "2.0", + ID: 1, + Result: json.RawMessage("null"), + Error: &jsonRPCError{Code: -32000, Message: "method handler crashed"}, + } + }) + defer server.Close() + + txHash := common.HexToHash("0xcafe") + _, err := traceOneTransaction(context.Background(), server.URL, txHash) + require.Error(t, err) + require.ErrorContains(t, err, "method handler crashed") + require.ErrorContains(t, err, txHash.Hex()) +} + +// When continueOnError=true, failed traces are collected in failedTraces and +// do not abort the run; successful traces still return their addresses. +func TestTraceTransactions_ContinueOnError_CollectsFailed(t *testing.T) { + t.Parallel() + + goodHash := common.HexToHash("0x0000000000000000000000000000000000000000000000000000000000001111") + badHash := common.HexToHash("0x0000000000000000000000000000000000000000000000000000000000002222") + addrGood := testAddr1 + + server := newTraceServer(t, func(txHex string) jsonRPCResponse { + if txHex == goodHash.Hex() { + return jsonRPCResponse{ + JSONRPC: "2.0", + ID: 1, + Result: json.RawMessage(`{"pre":{"` + addrGood + `":{}},"post":{}}`), + } + } + return jsonRPCResponse{ + JSONRPC: "2.0", + ID: 1, + Error: &jsonRPCError{Code: -32000, Message: "trace failed"}, + } + }) + defer server.Close() + + addrs, failed, err := traceTransactions( + context.Background(), server.URL, + []common.Hash{goodHash, badHash}, 1, true, + ) + require.NoError(t, err) + require.Len(t, addrs, 1) + require.Equal(t, common.HexToAddress(addrGood), addrs[0]) + require.Len(t, failed, 1) + require.Equal(t, badHash, failed[0].Hash) +} + +// When continueOnError=false, the first trace failure aborts the run. +func TestTraceTransactions_AbortOnError(t *testing.T) { + t.Parallel() + + server := newTraceServer(t, func(_ string) jsonRPCResponse { + return jsonRPCResponse{ + JSONRPC: "2.0", + ID: 1, + Error: &jsonRPCError{Code: -32000, Message: "archive node required"}, + } + }) + defer server.Close() + + _, _, err := traceTransactions( + context.Background(), server.URL, + []common.Hash{common.HexToHash("0x9999")}, 1, false, + ) + require.Error(t, err) + require.ErrorContains(t, err, "trace failures") +} + +// TestRunStepA_AbortOnTraceError verifies that RunStepA returns an error (and does not +// silently continue) when a debug_traceTransaction call fails and IgnoreOnTraceError=false. +// +// Before the fix, collectResults drained every result from the worker pool before +// returning the error — i.e. all remaining transactions in the window were still traced. +// The fix adds context cancellation so in-flight workers abort as soon as the first +// failure is detected. +// +// The test uses two transactions in a single block window with ConcurrencyLimit=1 so they +// are dispatched sequentially. The first trace always fails; the second should be cancelled +// before it is sent, proving that the worker pool stops early rather than tracing everything. +func TestRunStepA_AbortOnTraceError(t *testing.T) { + t.Parallel() + + const ( + txHex = "0x0000000000000000000000000000000000000000000000000000000000001234" + txHex2 = "0x0000000000000000000000000000000000000000000000000000000000005678" + ) + + var traceCalls atomic.Int32 + + // The server must handle two call shapes: + // • batch (body starts with '[') — eth_getBlockByNumber from scanBlockHeaders + // • single (body starts with '{') — debug_traceTransaction from traceOneTransaction + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + w.Header().Set("Content-Type", "application/json") + + if len(body) > 0 && body[0] == '[' { + var reqs []jsonRPCRequest + require.NoError(t, json.Unmarshal(body, &reqs)) + resps := make([]jsonRPCResponse, len(reqs)) + for i, req := range reqs { + resps[i] = jsonRPCResponse{ + JSONRPC: "2.0", + ID: req.ID, + Result: json.RawMessage(`{"transactions":["` + txHex + `","` + txHex2 + `"]}`), + } + } + require.NoError(t, json.NewEncoder(w).Encode(resps)) + return + } + + // Single request: count and always fail the trace. + traceCalls.Add(1) + require.NoError(t, json.NewEncoder(w).Encode(jsonRPCResponse{ + JSONRPC: "2.0", + ID: 1, + Error: &jsonRPCError{Code: -32000, Message: "trace not available"}, + })) + })) + defer server.Close() + + cfg := &Config{ + L2RPCURL: server.URL, + Options: Options{ + L2StartBlock: 0, + StepAWindowSize: 1, + RPCBatchSize: 1, + ConcurrencyLimit: 1, + IgnoreOnTraceError: false, + }, + } + + _, err := RunStepA(context.Background(), cfg, 0) + require.Error(t, err) + require.ErrorContains(t, err, "trace transactions") + require.ErrorContains(t, err, "trace not available") + // With abort-on-first-failure the second tx must not be traced. + require.Less(t, traceCalls.Load(), int32(2), "worker pool must abort after the first trace failure") +} + +func TestHexToUint64(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + expected uint64 + }{ + {"zero", "0x0", 0}, + {"simple", "0x1", 1}, + {"hex value", "0xff", 255}, + {"no prefix", "ff", 255}, + {"block number", "0x1a2b3c", 1715004}, + {"large", "0xFFFFFFFF", 4294967295}, + {"mixed case", "0xAbCdEf", 11259375}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + result := hexToUint64(tt.input) + require.Equal(t, tt.expected, result) + }) + } +} diff --git a/tools/exit_certificate/step_b.go b/tools/exit_certificate/step_b.go new file mode 100644 index 000000000..e8b579b08 --- /dev/null +++ b/tools/exit_certificate/step_b.go @@ -0,0 +1,417 @@ +package exit_certificate + +import ( + "context" + "encoding/json" + "fmt" + "math/big" + "sync" + + "github.com/agglayer/aggkit/log" + "github.com/ethereum/go-ethereum/common" +) + +const ( + // balanceOfSelector is the ERC20 balanceOf(address) function selector. + balanceOfSelector = "0x70a08231" + + // tokenConcurrency limits how many tokens are scanned in parallel (Step B Phase 3). + tokenConcurrency = 4 + + // abiWordSize is the size of an ABI-encoded word in bytes. + abiWordSize = 32 +) + +// RunStepB runs Step B1, B2, and B3 and returns the combined result. +// B1 classifies addresses and collects balances; B2 detects ERC-20 contracts; +// B3 fetches holder breakdowns for the contracts listed in ExtraERC20Contracts. +func RunStepB(ctx context.Context, cfg *Config, targetBlock uint64, stepA *StepAResult) (*StepBResult, error) { + b1Result, err := RunStepB1(ctx, cfg, targetBlock, stepA) + if err != nil { + return nil, err + } + eoaAddrs := filterEOAs(stepA.Addresses, b1Result.ContractAddresses) + b2Result, err := RunStepB2(ctx, cfg, targetBlock, b1Result.ContractAddresses, eoaAddrs, stepA.WrappedTokens) + if err != nil { + return nil, err + } + b3Result, err := RunStepB3(ctx, cfg, targetBlock, eoaAddrs, b2Result) + if err != nil { + return nil, err + } + log.Infof("STEP B complete: %d EOAs, %d token accumulations, %d ERC-20 detected, %d ERC-20 holder breakdowns", + len(b1Result.EOABalances), len(b1Result.Accumulated), + len(b2Result.DetectedERC20s), len(b3Result.Breakdowns)) + return &StepBResult{ + EOABalances: b1Result.EOABalances, + Accumulated: b1Result.Accumulated, + ContractAddresses: b1Result.ContractAddresses, + DetectedERC20s: b2Result.DetectedERC20s, + DiscardedERC20s: b2Result.DiscardedERC20s, + ERC20HolderBreakdowns: b3Result.Breakdowns, + }, nil +} + +// RunStepB1 classifies addresses as EOA vs contract, then collects ETH and wrapped +// token balances at targetBlock for all EOAs. +func RunStepB1(ctx context.Context, cfg *Config, targetBlock uint64, stepA *StepAResult) (*StepB1Result, error) { + log.Info("═══════════════════════════════════════════") + log.Info(" STEP B1 — EOA balance checking") + log.Info("═══════════════════════════════════════════") + + rpcURL := cfg.L2RPCURL + blockTag := toBlockTag(targetBlock) + batchSize := cfg.Options.RPCBatchSize + concurrency := cfg.Options.ConcurrencyLimit + + // Phase 1: classify EOA vs contract + eoaAddrs, contractAddrs, err := classifyAddresses(ctx, rpcURL, stepA.Addresses, blockTag, batchSize, concurrency) + if err != nil { + return nil, fmt.Errorf("classify addresses: %w", err) + } + log.Infof("EOAs: %d, Contracts: %d", len(eoaAddrs), len(contractAddrs)) + + // Phase 2: fetch ETH balances + eoaEthBalances, err := fetchETHBalances(ctx, rpcURL, eoaAddrs, blockTag, batchSize, concurrency) + if err != nil { + return nil, fmt.Errorf("fetch ETH balances: %w", err) + } + log.Infof("ETH: %d EOAs with non-zero balance", len(eoaEthBalances)) + + // Phase 3: fetch wrapped token balances (parallel across tokens) + tokenBalances := fetchAllTokenBalances(ctx, rpcURL, stepA.WrappedTokens, eoaAddrs, blockTag, batchSize, concurrency) + + // Build outputs + tokenLookup := make(map[common.Address]WrappedToken, len(stepA.WrappedTokens)) + for _, t := range stepA.WrappedTokens { + tokenLookup[t.WrappedTokenAddress] = t + } + + eoaBalances := buildEOABalances(eoaAddrs, eoaEthBalances, tokenBalances, tokenLookup) + accumulated := buildAccumulated(eoaEthBalances, tokenBalances, tokenLookup) + + if err := checkGenesisBalances( + ctx, rpcURL, eoaAddrs, contractAddrs, eoaEthBalances, blockTag, batchSize, concurrency, + ); err != nil { + if !cfg.Options.IgnoreGenesisBalance { + return nil, err + } + log.Warnf("Genesis balance check failed (ignoreGenesisBalance=true, continuing): %v", err) + } + + log.Infof("STEP B1 complete: %d EOAs with balances, %d token accumulations", + len(eoaBalances), len(accumulated)) + + return &StepB1Result{ + EOABalances: eoaBalances, + Accumulated: accumulated, + ContractAddresses: contractAddrs, + }, nil +} + +// filterEOAs returns all addresses in addrs that do not appear in contracts. +func filterEOAs(addrs, contracts []common.Address) []common.Address { + contractSet := make(map[common.Address]struct{}, len(contracts)) + for _, c := range contracts { + contractSet[c] = struct{}{} + } + eoas := make([]common.Address, 0, len(addrs)-len(contracts)) + for _, a := range addrs { + if _, isContract := contractSet[a]; !isContract { + eoas = append(eoas, a) + } + } + return eoas +} + +func padLeft(s string, length int) string { + if len(s) >= length { + return s + } + return fmt.Sprintf("%s%s", string(make([]byte, length-len(s))), s) +} + +// sumBalances returns the sum of all values in a map[common.Address]*big.Int. +func sumBalances(balances map[common.Address]*big.Int) *big.Int { + total := new(big.Int) + for _, bal := range balances { + total.Add(total, bal) + } + return total +} + +// checkGenesisBalances fetches ETH balances at block 0 for EOAs and contracts and returns +// an error if any account has a non-zero genesis balance, since that indicates a genesis +// preload that would inflate the exit certificate totals. +func checkGenesisBalances( + ctx context.Context, rpcURL string, + eoaAddrs, contractAddrs []common.Address, + eoaEthBalances map[common.Address]*big.Int, + blockTag string, batchSize, concurrency int, +) error { + scBalances, err := fetchETHBalances(ctx, rpcURL, contractAddrs, blockTag, batchSize, concurrency) + if err != nil { + return fmt.Errorf("fetch contract ETH balances: %w", err) + } + genesisBalances, err := fetchETHBalances(ctx, rpcURL, eoaAddrs, toBlockTag(0), batchSize, concurrency) + if err != nil { + return fmt.Errorf("fetch genesis ETH balances: %w", err) + } + if len(genesisBalances) == 0 { + return nil + } + for addr, bal := range genesisBalances { + log.Infof("🚨🚨🚨 Genesis ETH preload detected for %s: %s wei", addr.Hex(), bal.String()) + } + genesisSumStr := sumBalances(genesisBalances).String() + eoaEthSumStr := sumBalances(eoaEthBalances).String() + scBalancesStr := sumBalances(scBalances).String() + totalBalance := new(big.Int).Add(sumBalances(eoaEthBalances), sumBalances(scBalances)) + diffStr := new(big.Int).Sub(totalBalance, sumBalances(genesisBalances)).String() + maxLen := max(len(genesisSumStr), len(eoaEthSumStr), len(diffStr), len(scBalancesStr)) + log.Infof("Genesis ETH preload total: %s wei (%d accounts)", padLeft(genesisSumStr, maxLen), len(genesisBalances)) + log.Infof("Total EOA ETH : %s wei (%d accounts)", padLeft(eoaEthSumStr, maxLen), len(eoaEthBalances)) + log.Infof("Total contract ETH : %s wei (%d accounts)", padLeft(scBalancesStr, maxLen), len(scBalances)) + log.Infof(" -------------------------------") + log.Infof("Total genesis subtraction: %s wei (%d accounts)", padLeft(diffStr, maxLen), len(eoaEthBalances)) + return fmt.Errorf( + "genesis ETH preload detected in %d accounts: "+ + "balances at block 0 are non-zero, indicating this is not a real network", + len(genesisBalances), + ) +} + +// classifyAddresses separates addresses into EOA and contract via eth_getCode. +func classifyAddresses( + ctx context.Context, rpcURL string, addresses []common.Address, + blockTag string, batchSize, concurrency int, +) (eoas, contracts []common.Address, err error) { + log.Infof("Classifying %d addresses (EOA vs contract)...", len(addresses)) + + calls := make([]RPCCall, len(addresses)) + for i, addr := range addresses { + calls[i] = RPCCall{Method: "eth_getCode", Params: []any{addr.Hex(), blockTag}} + } + + results, err := concurrentBatchRPC(ctx, rpcURL, calls, batchSize, concurrency, "L2 RPC/getCode") + if err != nil { + return nil, nil, fmt.Errorf("batch getCode: %w", err) + } + + for idx, result := range results { + addr := addresses[idx] + if isEOAResult(result) { + eoas = append(eoas, addr) + } else { + contracts = append(contracts, addr) + } + } + + log.Infof(" Classification complete: EOAs: %d, Contracts: %d", len(eoas), len(contracts)) + return eoas, contracts, nil +} + +// isEOAResult returns true if the eth_getCode result indicates an EOA (no code). +func isEOAResult(result json.RawMessage) bool { + if result == nil { + return true + } + var code string + if json.Unmarshal(result, &code) != nil { + return true + } + return code == "" || code == "0x" +} + +// fetchETHBalances queries eth_getBalance for all addresses concurrently. +func fetchETHBalances( + ctx context.Context, rpcURL string, addresses []common.Address, + blockTag string, batchSize, concurrency int, +) (map[common.Address]*big.Int, error) { + log.Infof("Fetching ETH balances for %d EOAs...", len(addresses)) + + calls := make([]RPCCall, len(addresses)) + for i, addr := range addresses { + calls[i] = RPCCall{Method: "eth_getBalance", Params: []any{addr.Hex(), blockTag}} + } + + results, err := concurrentBatchRPC(ctx, rpcURL, calls, batchSize, concurrency, "L2 RPC/getBalance") + if err != nil { + return nil, fmt.Errorf("batch getBalance: %w", err) + } + + balances := make(map[common.Address]*big.Int) + for idx, result := range results { + bal := unmarshalHexBigInt(result) + if bal != nil && bal.Sign() > 0 { + balances[addresses[idx]] = bal + } + } + + log.Infof(" ETH balances complete: %d non-zero", len(balances)) + return balances, nil +} + +// fetchAllTokenBalances scans all wrapped tokens in parallel (limited by tokenConcurrency). +func fetchAllTokenBalances( + ctx context.Context, rpcURL string, tokens []WrappedToken, + eoaAddresses []common.Address, blockTag string, batchSize, concurrency int, +) map[common.Address]map[common.Address]*big.Int { + log.Infof("Fetching balances for %d wrapped tokens × %d EOAs...", len(tokens), len(eoaAddresses)) + + var mu sync.Mutex + tokenBalances := make(map[common.Address]map[common.Address]*big.Int) + sem := make(chan struct{}, tokenConcurrency) + + var wg sync.WaitGroup + for _, token := range tokens { + wg.Add(1) + sem <- struct{}{} + go func(tok WrappedToken) { + defer wg.Done() + defer func() { <-sem }() + + balances, err := fetchTokenBalances( + ctx, rpcURL, tok.WrappedTokenAddress, + eoaAddresses, blockTag, batchSize, concurrency, + ) + if err != nil { + log.Warnf("Failed to fetch balances for token %s: %v", tok.WrappedTokenAddress.Hex(), err) + return + } + if len(balances) > 0 { + mu.Lock() + tokenBalances[tok.WrappedTokenAddress] = balances + mu.Unlock() + log.Infof(" Token %s...: %d holders", tok.WrappedTokenAddress.Hex()[:12], len(balances)) + } + }(token) + } + wg.Wait() + + return tokenBalances +} + +// fetchTokenBalances queries ERC20 balanceOf for all EOAs for a single token. +func fetchTokenBalances( + ctx context.Context, rpcURL string, tokenAddr common.Address, + eoaAddresses []common.Address, blockTag string, batchSize, concurrency int, +) (map[common.Address]*big.Int, error) { + calls := make([]RPCCall, len(eoaAddresses)) + for i, addr := range eoaAddresses { + calls[i] = RPCCall{ + Method: "eth_call", + Params: []any{ + map[string]string{ + "to": tokenAddr.Hex(), + "data": encodeBalanceOf(addr), + }, + blockTag, + }, + } + } + + results, err := concurrentBatchRPC(ctx, rpcURL, calls, batchSize, concurrency, "L2 RPC/balanceOf") + if err != nil { + return nil, fmt.Errorf("batch balanceOf: %w", err) + } + + balances := make(map[common.Address]*big.Int) + for idx, result := range results { + bal := unmarshalHexBigInt(result) + if bal != nil && bal.Sign() > 0 { + balances[eoaAddresses[idx]] = bal + } + } + return balances, nil +} + +// encodeBalanceOf ABI-encodes a balanceOf(address) call. +func encodeBalanceOf(addr common.Address) string { + return balanceOfSelector + common.Bytes2Hex(common.LeftPadBytes(addr.Bytes(), abiWordSize)) +} + +// unmarshalHexBigInt extracts a *big.Int from a JSON-encoded hex string RPC result. +// Returns nil for absent/empty/zero results. +func unmarshalHexBigInt(result json.RawMessage) *big.Int { + if result == nil { + return nil + } + var hex string + if json.Unmarshal(result, &hex) != nil || hex == "" || hex == "0x" { + return nil + } + return hexToBigInt(hex) +} + +// buildEOABalances assembles per-address balance records. +func buildEOABalances( + eoaAddrs []common.Address, + ethBalances map[common.Address]*big.Int, + tokenBalances map[common.Address]map[common.Address]*big.Int, + tokenLookup map[common.Address]WrappedToken, +) []EOABalance { + var result []EOABalance + for _, addr := range eoaAddrs { + if entry, ok := buildSingleEOABalance(addr, ethBalances, tokenBalances, tokenLookup); ok { + result = append(result, entry) + } + } + return result +} + +func buildSingleEOABalance( + addr common.Address, + ethBalances map[common.Address]*big.Int, + tokenBalances map[common.Address]map[common.Address]*big.Int, + tokenLookup map[common.Address]WrappedToken, +) (EOABalance, bool) { + entry := EOABalance{Address: addr, ETHBalance: "0"} + + if bal, ok := ethBalances[addr]; ok { + entry.ETHBalance = bal.String() + } + + for tokenAddr, holders := range tokenBalances { + if bal, ok := holders[addr]; ok && bal.Sign() > 0 { + info := tokenLookup[tokenAddr] + entry.Tokens = append(entry.Tokens, EOATokenBalance{ + WrappedTokenAddress: tokenAddr, + OriginNetwork: info.OriginNetwork, + OriginTokenAddress: info.OriginTokenAddress, + Balance: bal.String(), + }) + } + } + + if entry.ETHBalance == "0" && len(entry.Tokens) == 0 { + return EOABalance{}, false + } + return entry, true +} + +// buildAccumulated sums balances per token across all EOAs. +func buildAccumulated( + ethBalances map[common.Address]*big.Int, + tokenBalances map[common.Address]map[common.Address]*big.Int, + tokenLookup map[common.Address]WrappedToken, +) []AccumulatedBalance { + result := make([]AccumulatedBalance, 0, len(tokenBalances)+1) + + result = append(result, AccumulatedBalance{ + WrappedTokenAddress: common.Address{}, + TotalBalance: sumBalances(ethBalances).String(), + }) + + for tokenAddr, holders := range tokenBalances { + info := tokenLookup[tokenAddr] + result = append(result, AccumulatedBalance{ + WrappedTokenAddress: tokenAddr, + OriginNetwork: info.OriginNetwork, + OriginTokenAddress: info.OriginTokenAddress, + TotalBalance: sumBalances(holders).String(), + }) + } + + return result +} diff --git a/tools/exit_certificate/step_b2.go b/tools/exit_certificate/step_b2.go new file mode 100644 index 000000000..114eb2a7d --- /dev/null +++ b/tools/exit_certificate/step_b2.go @@ -0,0 +1,285 @@ +package exit_certificate + +import ( + "context" + "encoding/json" + "fmt" + "math/big" + "sync" + "sync/atomic" + + "github.com/agglayer/aggkit/log" + "github.com/ethereum/go-ethereum/common" +) + +// RunStepB2 probes the contract addresses from Step B1 for the ERC-20 interface. +// For each contract that responds to totalSupply() with a non-zero value it checks +// whether it holds any of the tracked wrapped tokens: +// - holds at least one → DetectedERC20 (relevant to the certificate) +// - holds none → DiscardedERC20 (no tracked value locked inside) +// +// RPC execution errors on totalSupply() calls are silently treated as "not ERC-20". +func RunStepB2( + ctx context.Context, cfg *Config, targetBlock uint64, + contractAddrs, eoaAddrs []common.Address, + wrappedTokens []WrappedToken, +) (*StepB2Result, error) { + log.Info("═══════════════════════════════════════════") + log.Info(" STEP B2 — ERC-20 detection in contracts") + log.Info("═══════════════════════════════════════════") + + if len(contractAddrs) == 0 { + log.Info("No contract addresses to probe") + log.Info("STEP B2 complete: 0 ERC-20 contracts detected") + return &StepB2Result{}, nil + } + + blockTag := toBlockTag(targetBlock) + batchSize := cfg.Options.RPCBatchSize + concurrency := cfg.Options.ConcurrencyLimit + + log.Infof("Probing %d contracts for ERC-20 totalSupply()...", len(contractAddrs)) + erc20Supplies := detectERC20Contracts(ctx, cfg.L2RPCURL, contractAddrs, blockTag, concurrency) + log.Infof("%d/%d contracts responded to ERC20 totalSupply()", len(erc20Supplies), len(contractAddrs)) + + if len(erc20Supplies) == 0 { + log.Info("STEP B2 complete: 0 ERC-20 contracts detected") + return &StepB2Result{}, nil + } + + jobs := make([]erc20ProbeJob, 0, len(erc20Supplies)) + for addr, info := range erc20Supplies { + jobs = append(jobs, erc20ProbeJob{addr: addr, info: info}) + } + + detected := make([]DetectedERC20, 0, len(erc20Supplies)) + var discarded []DiscardedERC20 + + err := runWorkerPool( + ctx, jobs, concurrency, + func(j erc20ProbeJob) (erc20ProbeResult, error) { + wrappedBalances, err := checkWrappedTokenBalances( + ctx, cfg.L2RPCURL, j.addr, wrappedTokens, blockTag, batchSize, concurrency, + ) + if err != nil { + return erc20ProbeResult{}, fmt.Errorf("check wrapped balances for ERC-20 %s: %w", j.addr.Hex(), err) + } + + if len(wrappedBalances) == 0 { + log.Debugf(" discarded %s %q (%s) (no tracked wrapped tokens held)", j.addr.Hex(), j.info.name, j.info.symbol) + return erc20ProbeResult{discarded: &DiscardedERC20{ + Address: j.addr, + Name: j.info.name, + Symbol: j.info.symbol, + TotalSupply: j.info.supply.String(), + }}, nil + } + + log.Infof("⚠ ERC-20 %s %q (%s) locks tracked wrapped tokens:", j.addr.Hex(), j.info.name, j.info.symbol) + for _, wb := range wrappedBalances { + log.Infof(" → %s : %s", wb.Token.WrappedTokenAddress.Hex(), wb.Balance) + } + + return erc20ProbeResult{detected: &DetectedERC20{ + Address: j.addr, + Name: j.info.name, + Symbol: j.info.symbol, + TotalSupply: j.info.supply.String(), + WrappedTokenBalances: wrappedBalances, + }}, nil + }, + func(r erc20ProbeResult) { + if r.detected != nil { + detected = append(detected, *r.detected) + } else { + discarded = append(discarded, *r.discarded) + } + }, + "step_b2: ERC-20 probe", + ) + if err != nil { + return nil, err + } + + log.Infof("STEP B2 complete: %d relevant ERC-20(s), %d discarded", len(detected), len(discarded)) + + return &StepB2Result{ + DetectedERC20s: detected, + DiscardedERC20s: discarded, + }, nil +} + +// checkWrappedTokenBalances calls balanceOf(contractAddr) on each wrapped token contract +// and eth_getBalance for native ETH. Returns only entries where the balance is > 0. +// ETH is represented as the zero-address token (OriginNetwork=0, OriginTokenAddress=0x0, +// WrappedTokenAddress=0x0). +func checkWrappedTokenBalances( + ctx context.Context, rpcURL string, + contractAddr common.Address, wrappedTokens []WrappedToken, + blockTag string, batchSize, concurrency int, +) ([]WrappedTokenBalance, error) { + // calls = [balanceOf(t0), ..., balanceOf(tN), eth_getBalance] + calls := make([]RPCCall, len(wrappedTokens)+1) + for i, t := range wrappedTokens { + calls[i] = RPCCall{ + Method: "eth_call", + Params: []any{ + map[string]string{ + "to": t.WrappedTokenAddress.Hex(), + "data": encodeBalanceOf(contractAddr), + }, + blockTag, + }, + } + } + calls[len(wrappedTokens)] = RPCCall{ + Method: "eth_getBalance", + Params: []any{contractAddr.Hex(), blockTag}, + } + + results, err := concurrentBatchRPC(ctx, rpcURL, calls, batchSize, concurrency, "") + if err != nil { + return nil, err + } + + var balances []WrappedTokenBalance + for i, result := range results[:len(wrappedTokens)] { + bal := unmarshalHexBigInt(result) + if bal != nil && bal.Sign() > 0 { + balances = append(balances, WrappedTokenBalance{ + Token: wrappedTokens[i], + Balance: bal.String(), + }) + } + } + if ethBal := unmarshalHexBigInt(results[len(wrappedTokens)]); ethBal != nil && ethBal.Sign() > 0 { + balances = append(balances, WrappedTokenBalance{ + Token: WrappedToken{}, // zero address = native ETH + Balance: ethBal.String(), + }) + } + return balances, nil +} + +// probeProgressPct is the granularity for progress logging in detectERC20Contracts. +const probeProgressPct = 10 + +// nameSelector is the function selector for ERC-20 name(). +const nameSelector = "0x06fdde03" + +// symbolSelector is the function selector for ERC-20 symbol(). +const symbolSelector = "0x95d89b41" + +// erc20Info holds the data fetched per contract during the ERC-20 probe. +type erc20Info struct { + supply *big.Int + name string + symbol string +} + +type erc20ProbeJob struct { + addr common.Address + info erc20Info +} + +type erc20ProbeResult struct { + detected *DetectedERC20 + discarded *DiscardedERC20 +} + +// detectERC20Contracts calls totalSupply() on each contract in parallel. +// For contracts with supply > 0 it also fetches name(). +// RPC execution errors (e.g. reverts on non-ERC-20 contracts) are silently ignored. +func detectERC20Contracts( + ctx context.Context, rpcURL string, contracts []common.Address, + blockTag string, concurrency int, +) map[common.Address]erc20Info { + type result struct { + addr common.Address + info erc20Info + } + + total := len(contracts) + resultCh := make(chan result, total) + sem := make(chan struct{}, concurrency) + + var done, detected atomic.Int32 + var wg sync.WaitGroup + for _, addr := range contracts { + wg.Add(1) + sem <- struct{}{} + go func(a common.Address) { + defer wg.Done() + defer func() { <-sem }() + + raw, err := singleRPC(ctx, rpcURL, "eth_call", []any{ + map[string]string{"to": a.Hex(), "data": totalSupplySelector}, + blockTag, + }, defaultRetries) + + var info erc20Info + if err == nil { + info.supply = unmarshalHexBigInt(raw) + } + + if info.supply != nil && info.supply.Sign() > 0 { + // Verify balanceOf(address(0)) succeeds to confirm the ERC-20 interface. + // Contracts that happen to match the totalSupply() selector but are not + // real ERC-20s will revert here. + _, balErr := singleRPC(ctx, rpcURL, "eth_call", []any{ + map[string]string{"to": a.Hex(), "data": encodeBalanceOf(common.Address{})}, + blockTag, + }, defaultRetries) + if balErr != nil { + info.supply = nil + } + } + + if info.supply != nil && info.supply.Sign() > 0 { + detected.Add(1) + nameRaw, nameErr := singleRPC(ctx, rpcURL, "eth_call", []any{ + map[string]string{"to": a.Hex(), "data": nameSelector}, + blockTag, + }, defaultRetries) + if nameErr == nil { + var nameHex string + if json.Unmarshal(nameRaw, &nameHex) == nil { + info.name = decodeABIString(common.FromHex(nameHex)) + } + } + + symbolRaw, symbolErr := singleRPC(ctx, rpcURL, "eth_call", []any{ + map[string]string{"to": a.Hex(), "data": symbolSelector}, + blockTag, + }, defaultRetries) + if symbolErr == nil { + var symbolHex string + if json.Unmarshal(symbolRaw, &symbolHex) == nil { + info.symbol = decodeABIString(common.FromHex(symbolHex)) + } + } + } + + n := int(done.Add(1)) + prevPct := (n - 1) * probeProgressPct / total + currPct := n * probeProgressPct / total + if currPct > prevPct || n == total { + log.Infof(" B2 ERC-20 probe: %d/%d (%d%%) — %d ERC-20(s) detected", + n, total, currPct*probeProgressPct, detected.Load()) + } + + resultCh <- result{addr: a, info: info} + }(addr) + } + + wg.Wait() + close(resultCh) + + erc20s := make(map[common.Address]erc20Info) + for r := range resultCh { + if r.info.supply != nil && r.info.supply.Sign() > 0 { + erc20s[r.addr] = r.info + } + } + return erc20s +} diff --git a/tools/exit_certificate/step_b2_test.go b/tools/exit_certificate/step_b2_test.go new file mode 100644 index 000000000..97073ac4c --- /dev/null +++ b/tools/exit_certificate/step_b2_test.go @@ -0,0 +1,443 @@ +package exit_certificate + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "math/big" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +const ( + rpcMethodEthCall = "eth_call" + rpcMethodEthGetBalance = "eth_getBalance" + rpcMethodEthGetLogs = "eth_getLogs" + rpcMethodEthGetBlockByNumber = "eth_getBlockByNumber" +) + +// rpcTestCall holds the decoded parts of a single JSON-RPC request +// received by a test server. +type rpcTestCall struct { + Method string // "eth_call", "eth_getBalance", … + To string // lowercase hex addr + Selector string // first 10 chars of data ("0x" + 8 hex) + FullData string // lowercase data without "0x" +} + +// newEthCallServer creates a test server that handles both single and batch +// JSON-RPC requests. respond is called once per sub-request; returning a +// non-nil *jsonRPCError sends an RPC error in the response. +func newEthCallServer(t *testing.T, respond func(rpcTestCall) (json.RawMessage, *jsonRPCError)) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + w.Header().Set("Content-Type", "application/json") + + decode := func(raw json.RawMessage) jsonRPCResponse { + var req struct { + Method string `json:"method"` + Params []json.RawMessage `json:"params"` + ID int `json:"id"` + } + _ = json.Unmarshal(raw, &req) + tc := rpcTestCall{Method: req.Method} + if len(req.Params) > 0 { + switch req.Method { + case rpcMethodEthCall: + var obj struct { + To string `json:"to"` + Data string `json:"data"` + } + _ = json.Unmarshal(req.Params[0], &obj) + tc.To = strings.ToLower(obj.To) + tc.FullData = strings.ToLower(strings.TrimPrefix(obj.Data, "0x")) + if len(obj.Data) >= 10 { + tc.Selector = strings.ToLower(obj.Data[:10]) + } + case rpcMethodEthGetBalance: + var addr string + _ = json.Unmarshal(req.Params[0], &addr) + tc.To = strings.ToLower(addr) + } + } + result, rpcErr := respond(tc) + if rpcErr != nil { + return jsonRPCResponse{JSONRPC: "2.0", ID: req.ID, Error: rpcErr} + } + return jsonRPCResponse{JSONRPC: "2.0", ID: req.ID, Result: result} + } + + trimmed := bytes.TrimSpace(body) + if len(trimmed) > 0 && trimmed[0] == '[' { + var rawReqs []json.RawMessage + require.NoError(t, json.Unmarshal(body, &rawReqs)) + resps := make([]jsonRPCResponse, len(rawReqs)) + for i, raw := range rawReqs { + resps[i] = decode(raw) + } + require.NoError(t, json.NewEncoder(w).Encode(resps)) + } else { + resp := decode(json.RawMessage(body)) + require.NoError(t, json.NewEncoder(w).Encode(resp)) + } + })) +} + +// abiUint256 ABI-encodes n as a 32-byte hex JSON string. +func abiUint256(n *big.Int) json.RawMessage { + b := common.LeftPadBytes(n.Bytes(), 32) + return json.RawMessage(`"0x` + common.Bytes2Hex(b) + `"`) +} + +// abiZero returns an ABI-encoded zero uint256. +func abiZero() json.RawMessage { return abiUint256(new(big.Int)) } + +// abiString ABI-encodes s as a dynamic string return value (offset | length | data). +func abiString(s string) json.RawMessage { + offset := "0000000000000000000000000000000000000000000000000000000000000020" + length := fmt.Sprintf("%064x", len(s)) + data := common.Bytes2Hex([]byte(s)) + for len(data)%64 != 0 { + data += "00" + } + return json.RawMessage(`"0x` + offset + length + data + `"`) +} + +// revertErr returns an RPC error representing a contract revert (not retried by batchRPC). +func revertErr() *jsonRPCError { + return &jsonRPCError{Code: 3, Message: "execution reverted"} +} + +// addrLow returns the lowercase hex of addr, matching tc.To comparisons. +func addrLow(addr common.Address) string { return strings.ToLower(addr.Hex()) } + +// eoaFromData extracts the queried address from a balanceOf call's FullData. +// The parameter starts at offset 8 (after 8-char selector) and the last 40 +// chars encode the 20-byte address. +func eoaFromData(fullData string) string { + if len(fullData) < 72 { + return "" + } + return "0x" + fullData[32:] +} + +// --- checkWrappedTokenBalances --- + +func TestCheckWrappedTokenBalances_AllZeroReturnEmpty(t *testing.T) { + t.Parallel() + + contractAddr := common.HexToAddress("0xC001000000000000000000000000000000000001") + server := newEthCallServer(t, func(_ rpcTestCall) (json.RawMessage, *jsonRPCError) { + return abiZero(), nil + }) + defer server.Close() + + balances, err := checkWrappedTokenBalances( + context.Background(), server.URL, contractAddr, nil, "latest", 200, 5, + ) + require.NoError(t, err) + require.Empty(t, balances) +} + +func TestCheckWrappedTokenBalances_ETHBalanceOnly(t *testing.T) { + t.Parallel() + + contractAddr := common.HexToAddress("0xC001000000000000000000000000000000000001") + ethBal := big.NewInt(5_000_000) + + server := newEthCallServer(t, func(tc rpcTestCall) (json.RawMessage, *jsonRPCError) { + if tc.Method == rpcMethodEthGetBalance { + return abiUint256(ethBal), nil + } + return abiZero(), nil + }) + defer server.Close() + + balances, err := checkWrappedTokenBalances( + context.Background(), server.URL, contractAddr, nil, "latest", 200, 5, + ) + require.NoError(t, err) + require.Len(t, balances, 1) + require.Equal(t, common.Address{}, balances[0].Token.WrappedTokenAddress) // zero addr = native ETH + require.Equal(t, ethBal.String(), balances[0].Balance) +} + +func TestCheckWrappedTokenBalances_WrappedTokenHeld(t *testing.T) { + t.Parallel() + + contractAddr := common.HexToAddress("0xC001000000000000000000000000000000000001") + tokenAddr := common.HexToAddress("0xABCD000000000000000000000000000000000001") + tokenBal := big.NewInt(999_000) + + wrappedTokens := []WrappedToken{{ + WrappedTokenAddress: tokenAddr, + OriginNetwork: 1, + OriginTokenAddress: common.HexToAddress("0x0101010101010101010101010101010101010101"), + }} + + server := newEthCallServer(t, func(tc rpcTestCall) (json.RawMessage, *jsonRPCError) { + if tc.Method == rpcMethodEthCall && tc.To == addrLow(tokenAddr) { + return abiUint256(tokenBal), nil + } + return abiZero(), nil + }) + defer server.Close() + + balances, err := checkWrappedTokenBalances( + context.Background(), server.URL, contractAddr, wrappedTokens, "latest", 200, 5, + ) + require.NoError(t, err) + require.Len(t, balances, 1) + require.Equal(t, tokenAddr, balances[0].Token.WrappedTokenAddress) + require.Equal(t, uint32(1), balances[0].Token.OriginNetwork) + require.Equal(t, tokenBal.String(), balances[0].Balance) +} + +func TestCheckWrappedTokenBalances_ETHAndTokenBothNonZero(t *testing.T) { + t.Parallel() + + contractAddr := common.HexToAddress("0xC001000000000000000000000000000000000001") + tokenAddr := common.HexToAddress("0xABCD000000000000000000000000000000000002") + + server := newEthCallServer(t, func(tc rpcTestCall) (json.RawMessage, *jsonRPCError) { + if tc.Method == rpcMethodEthGetBalance { + return abiUint256(big.NewInt(1_000_000)), nil + } + if tc.Method == rpcMethodEthCall && tc.To == addrLow(tokenAddr) { + return abiUint256(big.NewInt(500)), nil + } + return abiZero(), nil + }) + defer server.Close() + + balances, err := checkWrappedTokenBalances( + context.Background(), server.URL, contractAddr, + []WrappedToken{{WrappedTokenAddress: tokenAddr}}, "latest", 200, 5, + ) + require.NoError(t, err) + require.Len(t, balances, 2) +} + +// --- detectERC20Contracts --- + +func TestDetectERC20Contracts_Empty(t *testing.T) { + t.Parallel() + + result := detectERC20Contracts(context.Background(), "http://unused", nil, "latest", 5) + require.Empty(t, result) +} + +func TestDetectERC20Contracts_ZeroTotalSupply(t *testing.T) { + t.Parallel() + + contractAddr := common.HexToAddress("0xC001000000000000000000000000000000000001") + server := newEthCallServer(t, func(_ rpcTestCall) (json.RawMessage, *jsonRPCError) { + return abiZero(), nil // totalSupply = 0 → not ERC-20 + }) + defer server.Close() + + result := detectERC20Contracts(context.Background(), server.URL, []common.Address{contractAddr}, "latest", 5) + require.Empty(t, result) +} + +func TestDetectERC20Contracts_BalanceOfZeroReverts(t *testing.T) { + t.Parallel() + + // Contracts that have a totalSupply-like selector but revert on balanceOf(address(0)) + // should not be classified as ERC-20. + contractAddr := common.HexToAddress("0xC001000000000000000000000000000000000001") + server := newEthCallServer(t, func(tc rpcTestCall) (json.RawMessage, *jsonRPCError) { + if tc.Selector == totalSupplySelector { + return abiUint256(big.NewInt(1000)), nil + } + return nil, revertErr() + }) + defer server.Close() + + result := detectERC20Contracts(context.Background(), server.URL, []common.Address{contractAddr}, "latest", 5) + require.Empty(t, result) +} + +func TestDetectERC20Contracts_ValidERC20WithNameAndSymbol(t *testing.T) { + t.Parallel() + + contractAddr := common.HexToAddress("0xC001000000000000000000000000000000000001") + supply := big.NewInt(1_000_000) + + server := newEthCallServer(t, func(tc rpcTestCall) (json.RawMessage, *jsonRPCError) { + switch tc.Selector { + case totalSupplySelector: + return abiUint256(supply), nil + case nameSelector: + return abiString("MyToken"), nil + case symbolSelector: + return abiString("MTK"), nil + default: + return abiZero(), nil // balanceOf(address(0)) succeeds → confirms ERC-20 + } + }) + defer server.Close() + + result := detectERC20Contracts( + context.Background(), server.URL, []common.Address{contractAddr}, "latest", 5, + ) + require.Len(t, result, 1) + info, ok := result[contractAddr] + require.True(t, ok) + require.Equal(t, supply, info.supply) + require.Equal(t, "MyToken", info.name) + require.Equal(t, "MTK", info.symbol) +} + +func TestDetectERC20Contracts_MultipleContracts(t *testing.T) { + t.Parallel() + + erc20Addr := common.HexToAddress("0xAAAA000000000000000000000000000000000001") + nonERC20Addr := common.HexToAddress("0xBBBB000000000000000000000000000000000002") + + server := newEthCallServer(t, func(tc rpcTestCall) (json.RawMessage, *jsonRPCError) { + if tc.To == addrLow(erc20Addr) && tc.Selector == totalSupplySelector { + return abiUint256(big.NewInt(500)), nil + } + if tc.To == addrLow(nonERC20Addr) && tc.Selector == totalSupplySelector { + return abiZero(), nil // zero supply → filtered out + } + return abiZero(), nil // balanceOf succeeds for erc20Addr + }) + defer server.Close() + + contracts := []common.Address{erc20Addr, nonERC20Addr} + result := detectERC20Contracts(context.Background(), server.URL, contracts, "latest", 5) + require.Len(t, result, 1) + _, ok := result[erc20Addr] + require.True(t, ok) +} + +// --- RunStepB2 --- + +func TestRunStepB2_EmptyContractAddresses(t *testing.T) { + t.Parallel() + + cfg := &Config{ + L2RPCURL: "http://unused", + Options: Options{RPCBatchSize: 200, ConcurrencyLimit: 5}, + } + result, err := RunStepB2(context.Background(), cfg, 0, nil, nil, nil) + require.NoError(t, err) + require.Empty(t, result.DetectedERC20s) + require.Empty(t, result.DiscardedERC20s) +} + +func TestRunStepB2_NoERC20sDetected(t *testing.T) { + t.Parallel() + + contractAddr := common.HexToAddress("0xC001000000000000000000000000000000000001") + server := newEthCallServer(t, func(_ rpcTestCall) (json.RawMessage, *jsonRPCError) { + return abiZero(), nil // totalSupply = 0 → no ERC-20s + }) + defer server.Close() + + cfg := &Config{ + L2RPCURL: server.URL, + Options: Options{RPCBatchSize: 200, ConcurrencyLimit: 5}, + } + result, err := RunStepB2(context.Background(), cfg, 0, []common.Address{contractAddr}, nil, nil) + require.NoError(t, err) + require.Empty(t, result.DetectedERC20s) + require.Empty(t, result.DiscardedERC20s) +} + +func TestRunStepB2_DiscardedERC20_HoldsNoTrackedTokens(t *testing.T) { + t.Parallel() + + contractAddr := common.HexToAddress("0xC001000000000000000000000000000000000001") + + server := newEthCallServer(t, func(tc rpcTestCall) (json.RawMessage, *jsonRPCError) { + switch tc.Selector { + case totalSupplySelector: + return abiUint256(big.NewInt(1000)), nil + case nameSelector: + return abiString("VaultToken"), nil + case symbolSelector: + return abiString("VT"), nil + default: + return abiZero(), nil // balanceOf(0x0) ok; no tracked token held + } + }) + defer server.Close() + + wrappedTokens := []WrappedToken{{ + WrappedTokenAddress: common.HexToAddress("0xDDDD000000000000000000000000000000000001"), + }} + + cfg := &Config{ + L2RPCURL: server.URL, + Options: Options{RPCBatchSize: 200, ConcurrencyLimit: 5}, + } + result, err := RunStepB2(context.Background(), cfg, 0, + []common.Address{contractAddr}, nil, wrappedTokens) + require.NoError(t, err) + require.Empty(t, result.DetectedERC20s) + require.Len(t, result.DiscardedERC20s, 1) + require.Equal(t, contractAddr, result.DiscardedERC20s[0].Address) + require.Equal(t, "VaultToken", result.DiscardedERC20s[0].Name) + require.Equal(t, "VT", result.DiscardedERC20s[0].Symbol) +} + +func TestRunStepB2_DetectedERC20_HoldsTrackedToken(t *testing.T) { + t.Parallel() + + contractAddr := common.HexToAddress("0xC001000000000000000000000000000000000001") + tokenAddr := common.HexToAddress("0xABCD000000000000000000000000000000000001") + tokenBal := big.NewInt(800_000) + + server := newEthCallServer(t, func(tc rpcTestCall) (json.RawMessage, *jsonRPCError) { + switch { + case tc.To == addrLow(contractAddr) && tc.Selector == totalSupplySelector: + return abiUint256(big.NewInt(1000)), nil + case tc.To == addrLow(contractAddr) && tc.Selector == nameSelector: + return abiString("StakingPool"), nil + case tc.To == addrLow(contractAddr) && tc.Selector == symbolSelector: + return abiString("SP"), nil + case tc.To == addrLow(tokenAddr) && tc.Selector == balanceOfSelector: + // balanceOf(contractAddr) on the wrapped token: contract holds tokenBal + return abiUint256(tokenBal), nil + default: + return abiZero(), nil + } + }) + defer server.Close() + + wrappedTokens := []WrappedToken{{ + WrappedTokenAddress: tokenAddr, + OriginNetwork: 0, + OriginTokenAddress: common.HexToAddress("0x0202020202020202020202020202020202020202"), + }} + + cfg := &Config{ + L2RPCURL: server.URL, + Options: Options{RPCBatchSize: 200, ConcurrencyLimit: 5}, + } + result, err := RunStepB2(context.Background(), cfg, 0, + []common.Address{contractAddr}, nil, wrappedTokens) + require.NoError(t, err) + require.Empty(t, result.DiscardedERC20s) + require.Len(t, result.DetectedERC20s, 1) + + d := result.DetectedERC20s[0] + require.Equal(t, contractAddr, d.Address) + require.Equal(t, "StakingPool", d.Name) + require.Equal(t, "SP", d.Symbol) + require.Len(t, d.WrappedTokenBalances, 1) + require.Equal(t, tokenAddr, d.WrappedTokenBalances[0].Token.WrappedTokenAddress) + require.Equal(t, tokenBal.String(), d.WrappedTokenBalances[0].Balance) +} diff --git a/tools/exit_certificate/step_b3.go b/tools/exit_certificate/step_b3.go new file mode 100644 index 000000000..63f44b055 --- /dev/null +++ b/tools/exit_certificate/step_b3.go @@ -0,0 +1,68 @@ +package exit_certificate + +import ( + "context" + "fmt" + + "github.com/agglayer/aggkit/log" + "github.com/ethereum/go-ethereum/common" +) + +// RunStepB3 fetches the per-EOA token balance for each contract listed in +// cfg.Options.ExtraERC20Contracts. For each address, balanceOf is called for +// every EOA collected in Step A. Collateral info (tracked wrapped tokens held) +// is attached from the B2 detected list when available. +func RunStepB3( + ctx context.Context, cfg *Config, targetBlock uint64, + eoaAddrs []common.Address, + b2Result *StepB2Result, +) (*StepB3Result, error) { + log.Info("═══════════════════════════════════════════") + log.Info(" STEP B3 — Extra ERC-20 holder decomposition") + log.Info("═══════════════════════════════════════════") + + if len(cfg.Options.ExtraERC20Contracts) == 0 { + log.Info("No extra ERC-20 contracts configured — STEP B3 skipped") + return &StepB3Result{}, nil + } + + blockTag := toBlockTag(targetBlock) + batchSize := cfg.Options.RPCBatchSize + concurrency := cfg.Options.ConcurrencyLimit + + // Index all B2 detected contracts by address to attach collateral info. + b2Detected := make(map[common.Address]*DetectedERC20, len(b2Result.DetectedERC20s)) + for i := range b2Result.DetectedERC20s { + d := &b2Result.DetectedERC20s[i] + b2Detected[d.Address] = d + } + + log.Infof("Processing %d extra ERC-20 contract(s) against %d EOA(s)", + len(cfg.Options.ExtraERC20Contracts), len(eoaAddrs)) + + breakdowns := make([]ERC20HolderBreakdown, 0, len(cfg.Options.ExtraERC20Contracts)) + for _, addr := range cfg.Options.ExtraERC20Contracts { + log.Infof(" %s — fetching balances for %d EOA(s)...", addr.Hex(), len(eoaAddrs)) + holderBalances, err := fetchTokenBalances( + ctx, cfg.L2RPCURL, addr, eoaAddrs, blockTag, batchSize, concurrency, + ) + if err != nil { + return nil, fmt.Errorf("fetchTokenBalances for ERC-20 %s: %w", addr.Hex(), err) + } + + holders := make([]ERC20Holder, 0, len(holderBalances)) + for holderAddr, bal := range holderBalances { + holders = append(holders, ERC20Holder{Address: holderAddr, Balance: bal.String()}) + } + log.Infof(" %s — %d holder(s) found", addr.Hex(), len(holders)) + + breakdowns = append(breakdowns, ERC20HolderBreakdown{ + Address: addr, + Holders: holders, + Detected: b2Detected[addr], // nil when not in B2 detected list + }) + } + + log.Infof("STEP B3 complete: %d contract(s) processed", len(breakdowns)) + return &StepB3Result{Breakdowns: breakdowns}, nil +} diff --git a/tools/exit_certificate/step_b3_test.go b/tools/exit_certificate/step_b3_test.go new file mode 100644 index 000000000..d3e8109c5 --- /dev/null +++ b/tools/exit_certificate/step_b3_test.go @@ -0,0 +1,211 @@ +package exit_certificate + +import ( + "context" + "encoding/json" + "math/big" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +func TestRunStepB3_EmptyConfig_Skipped(t *testing.T) { + t.Parallel() + + cfg := &Config{ + L2RPCURL: "http://unused", + Options: Options{ + RPCBatchSize: 200, + ConcurrencyLimit: 5, + }, + } + result, err := RunStepB3(context.Background(), cfg, 0, nil, &StepB2Result{}) + require.NoError(t, err) + require.Empty(t, result.Breakdowns) +} + +func TestRunStepB3_DetectedFieldPopulated_FromB2(t *testing.T) { + t.Parallel() + + contractAddr := common.HexToAddress("0xAAAA000000000000000000000000000000000001") + eoa1 := common.HexToAddress("0x1111111111111111111111111111111111111111") + + server := newEthCallServer(t, func(tc rpcTestCall) (json.RawMessage, *jsonRPCError) { + if tc.To == addrLow(contractAddr) && tc.Selector == balanceOfSelector { + if strings.ToLower(eoaFromData(tc.FullData)) == addrLow(eoa1) { + return abiUint256(big.NewInt(150)), nil + } + } + return abiZero(), nil + }) + defer server.Close() + + b2Result := &StepB2Result{ + DetectedERC20s: []DetectedERC20{ + {Address: contractAddr, Name: "StakedToken", Symbol: "ST", TotalSupply: "1000"}, + }, + } + + cfg := &Config{ + L2RPCURL: server.URL, + Options: Options{ + RPCBatchSize: 200, + ConcurrencyLimit: 5, + ExtraERC20Contracts: []common.Address{contractAddr}, + }, + } + result, err := RunStepB3(context.Background(), cfg, 0, []common.Address{eoa1}, b2Result) + require.NoError(t, err) + require.Len(t, result.Breakdowns, 1) + bd := result.Breakdowns[0] + require.Len(t, bd.Holders, 1) + require.Equal(t, "150", bd.Holders[0].Balance) + require.NotNil(t, bd.Detected, "collateral info must be populated when contract is in B2 detected list") + require.Equal(t, "StakedToken", bd.Detected.Name) + require.Equal(t, "ST", bd.Detected.Symbol) +} + +func TestRunStepB3_FetchesHolders_NotInB2(t *testing.T) { + t.Parallel() + + contractAddr := common.HexToAddress("0xCCCC000000000000000000000000000000000001") + eoa1 := common.HexToAddress("0x1111111111111111111111111111111111111111") + eoa2 := common.HexToAddress("0x2222222222222222222222222222222222222222") + + server := newEthCallServer(t, func(tc rpcTestCall) (json.RawMessage, *jsonRPCError) { + if tc.To == addrLow(contractAddr) && tc.Selector == balanceOfSelector { + queried := strings.ToLower(eoaFromData(tc.FullData)) + switch queried { + case addrLow(eoa1): + return abiUint256(big.NewInt(400)), nil + case addrLow(eoa2): + return abiZero(), nil // zero balance → not in result + } + } + return abiZero(), nil + }) + defer server.Close() + + cfg := &Config{ + L2RPCURL: server.URL, + Options: Options{ + RPCBatchSize: 200, + ConcurrencyLimit: 5, + ExtraERC20Contracts: []common.Address{contractAddr}, + }, + } + result, err := RunStepB3(context.Background(), cfg, 0, []common.Address{eoa1, eoa2}, &StepB2Result{}) + require.NoError(t, err) + require.Len(t, result.Breakdowns, 1) + + bd := result.Breakdowns[0] + require.Equal(t, contractAddr, bd.Address) + require.Nil(t, bd.Detected, "no collateral info when contract was not in B2 detected list") + require.Len(t, bd.Holders, 1, "only eoa1 has non-zero balance") + require.Equal(t, eoa1, bd.Holders[0].Address) + require.Equal(t, "400", bd.Holders[0].Balance) +} + +func TestRunStepB3_NoEOAs_EmptyHolders(t *testing.T) { + t.Parallel() + + contractAddr := common.HexToAddress("0xCCCC000000000000000000000000000000000001") + server := newEthCallServer(t, func(_ rpcTestCall) (json.RawMessage, *jsonRPCError) { + return abiZero(), nil + }) + defer server.Close() + + cfg := &Config{ + L2RPCURL: server.URL, + Options: Options{ + RPCBatchSize: 200, + ConcurrencyLimit: 5, + ExtraERC20Contracts: []common.Address{contractAddr}, + }, + } + result, err := RunStepB3(context.Background(), cfg, 0, nil, &StepB2Result{}) + require.NoError(t, err) + require.Len(t, result.Breakdowns, 1) + require.Empty(t, result.Breakdowns[0].Holders) +} + +func TestRunStepB3_RPCError_ReturnsError(t *testing.T) { + t.Parallel() + + contractAddr := common.HexToAddress("0xCCCC000000000000000000000000000000000001") + eoa1 := common.HexToAddress("0x1111111111111111111111111111111111111111") + + // Server always returns HTTP 500. The context timeout cuts the backoff short + // so the test finishes in milliseconds instead of waiting for all retries. + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + cfg := &Config{ + L2RPCURL: server.URL, + Options: Options{ + RPCBatchSize: 1, + ConcurrencyLimit: 1, + ExtraERC20Contracts: []common.Address{contractAddr}, + }, + } + ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) + defer cancel() + _, err := RunStepB3(ctx, cfg, 0, []common.Address{eoa1}, &StepB2Result{}) + require.Error(t, err) + require.Contains(t, err.Error(), contractAddr.Hex()) +} + +func TestRunStepB3_MixedContracts(t *testing.T) { + t.Parallel() + + // addr1: in B2 detected list → Detected != nil + // addr2: not in B2 → Detected == nil + addr1 := common.HexToAddress("0xAAAA000000000000000000000000000000000001") + addr2 := common.HexToAddress("0xBBBB000000000000000000000000000000000002") + eoa1 := common.HexToAddress("0x1111111111111111111111111111111111111111") + + server := newEthCallServer(t, func(tc rpcTestCall) (json.RawMessage, *jsonRPCError) { + if tc.Selector == balanceOfSelector { + return abiUint256(big.NewInt(50)), nil + } + return abiZero(), nil + }) + defer server.Close() + + b2Result := &StepB2Result{ + DetectedERC20s: []DetectedERC20{ + {Address: addr1, Name: "TokenA"}, + }, + } + + cfg := &Config{ + L2RPCURL: server.URL, + Options: Options{ + RPCBatchSize: 200, + ConcurrencyLimit: 5, + ExtraERC20Contracts: []common.Address{addr1, addr2}, + }, + } + result, err := RunStepB3(context.Background(), cfg, 0, []common.Address{eoa1}, b2Result) + require.NoError(t, err) + require.Len(t, result.Breakdowns, 2) + + byAddr := make(map[common.Address]ERC20HolderBreakdown, 2) + for _, bd := range result.Breakdowns { + byAddr[bd.Address] = bd + } + + require.NotNil(t, byAddr[addr1].Detected) + require.Equal(t, "TokenA", byAddr[addr1].Detected.Name) + require.Len(t, byAddr[addr1].Holders, 1) + + require.Nil(t, byAddr[addr2].Detected) + require.Len(t, byAddr[addr2].Holders, 1) +} diff --git a/tools/exit_certificate/step_b_helpers_test.go b/tools/exit_certificate/step_b_helpers_test.go new file mode 100644 index 000000000..2a4a821f7 --- /dev/null +++ b/tools/exit_certificate/step_b_helpers_test.go @@ -0,0 +1,355 @@ +package exit_certificate + +import ( + "bytes" + "encoding/json" + "io" + "math/big" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +// --- pure helpers -------------------------------------------------------------------------------- + +func TestFilterEOAs(t *testing.T) { + t.Parallel() + a := common.HexToAddress("0xa") + b := common.HexToAddress("0xb") + c := common.HexToAddress("0xc") + + eoas := filterEOAs([]common.Address{a, b, c}, []common.Address{b}) + require.Equal(t, []common.Address{a, c}, eoas) + + // no contracts → all are EOAs + require.Equal(t, []common.Address{a, b}, filterEOAs([]common.Address{a, b}, nil)) +} + +func TestPadLeft(t *testing.T) { + t.Parallel() + require.Equal(t, "abc", padLeft("abc", 2)) // already long enough → unchanged + require.Len(t, padLeft("ab", 5), 5) + require.True(t, strings.HasSuffix(padLeft("ab", 5), "ab")) +} + +func TestSumBalances(t *testing.T) { + t.Parallel() + require.Equal(t, big.NewInt(0), sumBalances(nil)) + got := sumBalances(map[common.Address]*big.Int{ + common.HexToAddress("0x1"): big.NewInt(10), + common.HexToAddress("0x2"): big.NewInt(32), + }) + require.Equal(t, big.NewInt(42), got) +} + +func TestIsEOAResult(t *testing.T) { + t.Parallel() + require.True(t, isEOAResult(nil)) // absent → EOA + require.True(t, isEOAResult(json.RawMessage(`123`))) // non-string → treated as EOA + require.True(t, isEOAResult(json.RawMessage(`""`))) // empty code → EOA + require.True(t, isEOAResult(json.RawMessage(`"0x"`))) // no code → EOA + require.False(t, isEOAResult(json.RawMessage(`"0x6080"`))) // has code → contract +} + +func TestUnmarshalHexBigInt(t *testing.T) { + t.Parallel() + require.Nil(t, unmarshalHexBigInt(nil)) + require.Nil(t, unmarshalHexBigInt(json.RawMessage(`""`))) + require.Nil(t, unmarshalHexBigInt(json.RawMessage(`"0x"`))) + require.Nil(t, unmarshalHexBigInt(json.RawMessage(`123`))) // non-string → nil + require.Equal(t, big.NewInt(255), unmarshalHexBigInt(json.RawMessage(`"0xff"`))) +} + +func TestBuildSingleEOABalance(t *testing.T) { + t.Parallel() + addr := common.HexToAddress("0xeoa") + tokenAddr := common.HexToAddress("0xtok") + tokenLookup := map[common.Address]WrappedToken{ + tokenAddr: {WrappedTokenAddress: tokenAddr, OriginNetwork: 1, OriginTokenAddress: common.HexToAddress("0xorig")}, + } + + // no ETH and no tokens → not included + _, ok := buildSingleEOABalance(addr, nil, nil, tokenLookup) + require.False(t, ok) + + // ETH only + entry, ok := buildSingleEOABalance(addr, + map[common.Address]*big.Int{addr: big.NewInt(500)}, nil, tokenLookup) + require.True(t, ok) + require.Equal(t, "500", entry.ETHBalance) + require.Empty(t, entry.Tokens) + + // token only (zero ETH) + tokenBalances := map[common.Address]map[common.Address]*big.Int{ + tokenAddr: {addr: big.NewInt(7)}, + } + entry, ok = buildSingleEOABalance(addr, nil, tokenBalances, tokenLookup) + require.True(t, ok) + require.Equal(t, "0", entry.ETHBalance) + require.Len(t, entry.Tokens, 1) + require.Equal(t, "7", entry.Tokens[0].Balance) + require.Equal(t, uint32(1), entry.Tokens[0].OriginNetwork) +} + +func TestBuildEOABalances(t *testing.T) { + t.Parallel() + a := common.HexToAddress("0xa") + b := common.HexToAddress("0xb") // no balances → dropped + eth := map[common.Address]*big.Int{a: big.NewInt(1)} + + got := buildEOABalances([]common.Address{a, b}, eth, nil, nil) + require.Len(t, got, 1) + require.Equal(t, a, got[0].Address) +} + +func TestBuildAccumulated(t *testing.T) { + t.Parallel() + tokenAddr := common.HexToAddress("0xtok") + eth := map[common.Address]*big.Int{ + common.HexToAddress("0x1"): big.NewInt(3), + common.HexToAddress("0x2"): big.NewInt(4), + } + tokenBalances := map[common.Address]map[common.Address]*big.Int{ + tokenAddr: {common.HexToAddress("0x1"): big.NewInt(10)}, + } + tokenLookup := map[common.Address]WrappedToken{ + tokenAddr: {WrappedTokenAddress: tokenAddr, OriginNetwork: 2}, + } + + got := buildAccumulated(eth, tokenBalances, tokenLookup) + require.Len(t, got, 2) // ETH entry + one token + + // first entry is the native ETH accumulation (zero address) + require.Equal(t, common.Address{}, got[0].WrappedTokenAddress) + require.Equal(t, "7", got[0].TotalBalance) + + require.Equal(t, tokenAddr, got[1].WrappedTokenAddress) + require.Equal(t, "10", got[1].TotalBalance) + require.Equal(t, uint32(2), got[1].OriginNetwork) +} + +// --- RPC fan-out functions via a batch JSON-RPC stub --------------------------------------------- + +// newBatchRPCServer answers JSON-RPC requests, batched (array) or single (object), dispatching each +// to resultFor(method, params) for the result value. This covers both concurrentBatchRPC (arrays) +// and singleRPC (single objects). +func newBatchRPCServer(t *testing.T, resultFor func(method string, params []json.RawMessage) any) string { + t.Helper() + type rpcReq struct { + ID json.RawMessage `json:"id"` + Method string `json:"method"` + Params []json.RawMessage `json:"params"` + } + respOf := func(req rpcReq) map[string]any { + return map[string]any{"jsonrpc": "2.0", "id": req.ID, "result": resultFor(req.Method, req.Params)} + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + w.Header().Set("Content-Type", "application/json") + trimmed := bytes.TrimSpace(body) + if len(trimmed) > 0 && trimmed[0] == '[' { + var reqs []rpcReq + require.NoError(t, json.Unmarshal(trimmed, &reqs)) + resps := make([]map[string]any, len(reqs)) + for i, req := range reqs { + resps[i] = respOf(req) + } + require.NoError(t, json.NewEncoder(w).Encode(resps)) + return + } + var req rpcReq + require.NoError(t, json.Unmarshal(trimmed, &req)) + require.NoError(t, json.NewEncoder(w).Encode(respOf(req))) + })) + t.Cleanup(srv.Close) + return srv.URL +} + +// firstAddr decodes the first JSON-RPC param as an address hex string. +func firstAddr(t *testing.T, params []json.RawMessage) common.Address { + t.Helper() + require.NotEmpty(t, params) + var s string + require.NoError(t, json.Unmarshal(params[0], &s)) + return common.HexToAddress(s) +} + +func TestClassifyAddresses(t *testing.T) { + t.Parallel() + contract := common.HexToAddress("0xcc") + eoa1 := common.HexToAddress("0x01") + eoa2 := common.HexToAddress("0x02") + + url := newBatchRPCServer(t, func(method string, params []json.RawMessage) any { + require.Equal(t, rpcMethodEthGetCode, method) + if firstAddr(t, params) == contract { + return "0x6080604052" // has code → contract + } + return "0x" + }) + + eoas, contracts, err := classifyAddresses(t.Context(), url, + []common.Address{eoa1, contract, eoa2}, "latest", 10, 2) + require.NoError(t, err) + require.ElementsMatch(t, []common.Address{eoa1, eoa2}, eoas) + require.Equal(t, []common.Address{contract}, contracts) +} + +func TestFetchETHBalances(t *testing.T) { + t.Parallel() + rich := common.HexToAddress("0x01") + poor := common.HexToAddress("0x02") + + url := newBatchRPCServer(t, func(method string, params []json.RawMessage) any { + require.Equal(t, rpcMethodEthGetBalance, method) + if firstAddr(t, params) == rich { + return "0x64" // 100 + } + return "0x0" + }) + + balances, err := fetchETHBalances(t.Context(), url, + []common.Address{rich, poor}, "latest", 10, 2) + require.NoError(t, err) + require.Len(t, balances, 1) // only non-zero kept + require.Equal(t, big.NewInt(100), balances[rich]) +} + +func TestFetchAllTokenBalances(t *testing.T) { + t.Parallel() + token := WrappedToken{WrappedTokenAddress: common.HexToAddress("0xtok"), OriginNetwork: 1} + holder := common.HexToAddress("0x01") + other := common.HexToAddress("0x02") + + url := newBatchRPCServer(t, func(method string, params []json.RawMessage) any { + require.Equal(t, "eth_call", method) + // the balanceOf target address is encoded in the call data; decode the call object + var call struct { + Data string `json:"data"` + } + require.NoError(t, json.Unmarshal(params[0], &call)) + if strings.HasSuffix(call.Data, strings.TrimPrefix(holder.Hex(), "0x")) { + return "0x05" + } + return "0x0" + }) + + out := fetchAllTokenBalances(t.Context(), url, + []WrappedToken{token}, []common.Address{holder, other}, "latest", 10, 2) + require.Len(t, out, 1) + require.Equal(t, big.NewInt(5), out[token.WrappedTokenAddress][holder]) +} + +// blockTagOf decodes the second JSON-RPC param (the block tag) as a string. +func blockTagOf(t *testing.T, params []json.RawMessage) string { + t.Helper() + require.GreaterOrEqual(t, len(params), 2) + var s string + require.NoError(t, json.Unmarshal(params[1], &s)) + return s +} + +func stepBConfig(url string) *Config { + return &Config{ + L2RPCURL: url, + Options: Options{RPCBatchSize: 10, ConcurrencyLimit: 2}, + } +} + +// genesisTag is toBlockTag(0): the block tag the genesis-preload guard queries. +const genesisTag = "0x0" + +// rpcMethodEthGetCode is the eth_getCode method name (rpcMethodEthGetBalance lives in step_b2_test.go). +const rpcMethodEthGetCode = "eth_getCode" + +func TestRunStepB1HappyPath(t *testing.T) { + t.Parallel() + rich := common.HexToAddress("0x01") + poor := common.HexToAddress("0x02") + + url := newBatchRPCServer(t, func(method string, params []json.RawMessage) any { + switch method { + case rpcMethodEthGetCode: + return "0x" // all EOAs + case rpcMethodEthGetBalance: + if blockTagOf(t, params) == genesisTag { + return "0x0" // zero at genesis → guard passes + } + if firstAddr(t, params) == rich { + return "0x64" // 100 + } + return "0x0" + default: + return "0x0" + } + }) + + stepA := &StepAResult{Addresses: []common.Address{rich, poor}} + res, err := RunStepB1(t.Context(), stepBConfig(url), 100, stepA) + require.NoError(t, err) + require.Empty(t, res.ContractAddresses) + require.Len(t, res.EOABalances, 1) // only the rich EOA has a balance + require.Equal(t, rich, res.EOABalances[0].Address) + // accumulated always carries the native-ETH entry first + require.NotEmpty(t, res.Accumulated) + require.Equal(t, "100", res.Accumulated[0].TotalBalance) +} + +func TestRunStepB1GenesisPreloadAborts(t *testing.T) { + t.Parallel() + addr := common.HexToAddress("0x01") + url := newBatchRPCServer(t, func(method string, params []json.RawMessage) any { + switch method { + case rpcMethodEthGetCode: + return "0x" + case rpcMethodEthGetBalance: + return "0x64" // non-zero everywhere, including genesis → guard trips + default: + return "0x0" + } + }) + + stepA := &StepAResult{Addresses: []common.Address{addr}} + + // default: a genesis preload aborts Step B1 + _, err := RunStepB1(t.Context(), stepBConfig(url), 100, stepA) + require.Error(t, err) + + // ignoreGenesisBalance downgrades it to a warning and continues + cfg := stepBConfig(url) + cfg.Options.IgnoreGenesisBalance = true + res, err := RunStepB1(t.Context(), cfg, 100, stepA) + require.NoError(t, err) + require.NotNil(t, res) +} + +func TestRunStepB(t *testing.T) { + t.Parallel() + // All EOAs and no extra ERC-20s, so B2 and B3 short-circuit; this exercises the B1→B2→B3 + // orchestration in RunStepB end-to-end. + addr := common.HexToAddress("0x01") + url := newBatchRPCServer(t, func(method string, params []json.RawMessage) any { + switch method { + case rpcMethodEthGetCode: + return "0x" + case rpcMethodEthGetBalance: + if blockTagOf(t, params) == genesisTag { + return "0x0" + } + return "0x2a" // 42 + default: + return "0x0" + } + }) + + stepA := &StepAResult{Addresses: []common.Address{addr}} + res, err := RunStepB(t.Context(), stepBConfig(url), 100, stepA) + require.NoError(t, err) + require.Len(t, res.EOABalances, 1) + require.Empty(t, res.DetectedERC20s) + require.Empty(t, res.ERC20HolderBreakdowns) +} diff --git a/tools/exit_certificate/step_b_test.go b/tools/exit_certificate/step_b_test.go new file mode 100644 index 000000000..1c344a7b2 --- /dev/null +++ b/tools/exit_certificate/step_b_test.go @@ -0,0 +1,41 @@ +package exit_certificate + +import ( + "math/big" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestHexToBigInt(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + expected *big.Int + }{ + {"zero", "0x0", big.NewInt(0)}, + {"simple", "0x1", big.NewInt(1)}, + {"larger", "0xff", big.NewInt(255)}, + {"no prefix", "ff", big.NewInt(255)}, + {"empty", "", new(big.Int)}, + {"just 0x", "0x", new(big.Int)}, + { + "large number", + "0xde0b6b3a7640000", + func() *big.Int { + n, _ := new(big.Int).SetString("de0b6b3a7640000", 16) + return n + }(), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + result := hexToBigInt(tt.input) + require.Equal(t, tt.expected.String(), result.String()) + }) + } +} diff --git a/tools/exit_certificate/step_c.go b/tools/exit_certificate/step_c.go new file mode 100644 index 000000000..322c89359 --- /dev/null +++ b/tools/exit_certificate/step_c.go @@ -0,0 +1,198 @@ +package exit_certificate + +import ( + "fmt" + "math/big" + "strings" + + "github.com/agglayer/aggkit/log" +) + +// RunStepC computes the value locked in smart contracts for each token. +// +// Formula: SC_locked = LBT_totalSupply − accumulated_EOA_balances +// +// When ERC20HolderBreakdowns are present (from Step B3), the portion of each token +// held by a vault/staking contract is distributed proportionally to its holders as +// individual HolderBridge exits instead of a single exit to exitAddress. The +// corresponding SC_locked value is reduced by the amount distributed. +func RunStepC(lbtEntries []LBTEntry, stepB *StepBResult) (*StepCResult, error) { + log.Info("═══════════════════════════════════════════") + log.Info(" STEP C — SC-locked value extraction") + log.Info("═══════════════════════════════════════════") + log.Infof("LBT has %d entries", len(lbtEntries)) + log.Infof("ERC-20 contracts to distribute as individual bridges: %d", len(stepB.ERC20HolderBreakdowns)) + + lbtByToken := indexByAddress(lbtEntries) + + eoaByToken := make(map[string]*big.Int, len(stepB.Accumulated)) + for _, entry := range stepB.Accumulated { + key := strings.ToLower(entry.WrappedTokenAddress.Hex()) + eoaByToken[key] = parseDecimalBigInt(entry.TotalBalance) + } + + holderBridges, covered, err := processBreakdowns(stepB.ERC20HolderBreakdowns) + if err != nil { + return nil, err + } + + scLockedValues, nonZeroCount, err := computeSCLocked(lbtByToken, eoaByToken, covered) + if err != nil { + return nil, err + } + + for tokenKey, eoaTotal := range eoaByToken { + if _, exists := lbtByToken[tokenKey]; !exists && eoaTotal.Sign() > 0 { + log.Warnf("Token %s has EOA balance (%s) but is not in LBT — skipping", tokenKey, eoaTotal) + } + } + + log.Infof("STEP C complete: %d tokens analyzed, %d have SC-locked value, %d holder bridge exits", + len(scLockedValues), nonZeroCount, len(holderBridges)) + + return &StepCResult{SCLockedValues: scLockedValues, HolderBridges: holderBridges}, nil +} + +// processBreakdowns computes HolderBridge entries from ERC-20 holder breakdowns (Step B3). +// The collateral token and each holder's balance are treated as 1:1: each holder receives +// exactly their vault-token balance as the collateral token amount — no proportional +// scaling against totalSupply. +// +// Returns an error if the sum of holder amounts exceeds the vault's actual holdings +// (over-distribution), which would indicate corrupt balance data. +func processBreakdowns(breakdowns []ERC20HolderBreakdown) ([]HolderBridge, map[string]*big.Int, error) { + var holderBridges []HolderBridge + covered := make(map[string]*big.Int) // wrappedToken lowercaseHex → total covered + + for _, bd := range breakdowns { + if bd.Detected == nil || len(bd.Detected.WrappedTokenBalances) == 0 { + continue + } + + for _, wtb := range bd.Detected.WrappedTokenBalances { + contractHolds := parseDecimalBigInt(wtb.Balance) + if contractHolds.Sign() == 0 { + continue + } + + tokenKey := strings.ToLower(wtb.Token.WrappedTokenAddress.Hex()) + + distributed := new(big.Int) + for _, h := range bd.Holders { + amount := parseDecimalBigInt(h.Balance) + if amount.Sign() <= 0 { + continue + } + holderBridges = append(holderBridges, HolderBridge{ + VaultAddress: bd.Address, + WrappedTokenAddress: wtb.Token.WrappedTokenAddress, + OriginNetwork: wtb.Token.OriginNetwork, + OriginTokenAddress: wtb.Token.OriginTokenAddress, + HolderAddress: h.Address, + Amount: amount.String(), + }) + distributed.Add(distributed, amount) + } + + if distributed.Cmp(contractHolds) > 0 { + return nil, nil, fmt.Errorf( + "vault %s: holder balances sum (%s) exceeds vault holdings (%s) for token %s — corrupt balance data", + bd.Address.Hex(), distributed, contractHolds, wtb.Token.WrappedTokenAddress.Hex(), + ) + } + + remainder := new(big.Int).Sub(contractHolds, distributed) + log.Infof(" vault %s | token %s | total=%s | individual_bridges=%s (%d holder(s)) | to_exit_addr=%s", + bd.Address.Hex(), wtb.Token.WrappedTokenAddress.Hex(), + contractHolds, distributed, len(bd.Holders), remainder) + if remainder.Sign() > 0 { + log.Infof(" ↳ %s unattributed (contract holders not in EOA list) → will flow to exitAddress as SC-locked", + remainder) + } + + if covered[tokenKey] == nil { + covered[tokenKey] = new(big.Int) + } + covered[tokenKey].Add(covered[tokenKey], distributed) + } + } + + return holderBridges, covered, nil +} + +func computeSCLocked( + lbtByToken map[string]LBTEntry, + eoaByToken map[string]*big.Int, + covered map[string]*big.Int, +) ([]SCLockedValue, int, error) { + scLockedValues := make([]SCLockedValue, 0, len(lbtByToken)) + nonZeroCount := 0 + + for tokenKey, lbt := range lbtByToken { + lbtBalance := parseDecimalBigInt(lbt.Balance) + eoaTotal := new(big.Int) + if val, exists := eoaByToken[tokenKey]; exists { + eoaTotal.Set(val) + } + + locked := new(big.Int).Sub(lbtBalance, eoaTotal) + if locked.Sign() < 0 { + log.Warnf("Token %s: EOA total (%s) exceeds LBT (%s) by %s. Clamping to 0.", + lbt.WrappedTokenAddress.Hex(), eoaTotal, lbtBalance, new(big.Int).Neg(locked)) + locked = new(big.Int) + } + + holdersCovered := new(big.Int) + if coveredAmt, ok := covered[tokenKey]; ok { + beforeCoverage := new(big.Int).Set(locked) + locked.Sub(locked, coveredAmt) + if locked.Sign() < 0 { + return nil, 0, fmt.Errorf( + "token %s: holder bridge coverage (%s) exceeds SC-locked balance (%s); possible LBT or EOA data inconsistency", + lbt.WrappedTokenAddress.Hex(), coveredAmt, + new(big.Int).Add(locked, coveredAmt), + ) + } + holdersCovered.Set(coveredAmt) + log.Infof(" SC_locked[%s]: %s → %s (-%s to holder bridges; %s vault remainder → SCLockedValues → exitAddress)", + lbt.WrappedTokenAddress.Hex(), beforeCoverage, locked, coveredAmt, locked) + } + + if locked.Sign() > 0 { + nonZeroCount++ + } + + holdersCoveredStr := "" + if holdersCovered.Sign() > 0 { + holdersCoveredStr = holdersCovered.String() + } + + totalLocked := new(big.Int).Add(locked, holdersCovered) + + scLockedValues = append(scLockedValues, SCLockedValue{ + WrappedTokenAddress: lbt.WrappedTokenAddress, + OriginNetwork: lbt.OriginNetwork, + OriginTokenAddress: lbt.OriginTokenAddress, + LBTBalance: lbtBalance.String(), + EOAAccumulated: eoaTotal.String(), + ERC20HoldersCovered: holdersCoveredStr, + TotalSCLockedBalance: totalLocked.String(), + PendingSCLockedBalance: locked.String(), + }) + } + + return scLockedValues, nonZeroCount, nil +} + +// indexByAddress indexes LBT entries by lowercased hex address. +// The native token entry (WrappedTokenAddress == zero address) is intentionally +// included: it maps to "0x0000...0000" and is treated the same as wrapped tokens +// for SC-locked value computation. Step D handles the native token distinction +// when building BridgeExit entries. +func indexByAddress(entries []LBTEntry) map[string]LBTEntry { + m := make(map[string]LBTEntry, len(entries)) + for _, e := range entries { + m[strings.ToLower(e.WrappedTokenAddress.Hex())] = e + } + return m +} diff --git a/tools/exit_certificate/step_c_test.go b/tools/exit_certificate/step_c_test.go new file mode 100644 index 000000000..9a4973e06 --- /dev/null +++ b/tools/exit_certificate/step_c_test.go @@ -0,0 +1,286 @@ +package exit_certificate + +import ( + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +func TestRunStepC_Basic(t *testing.T) { + t.Parallel() + + tokenAddr := common.HexToAddress("0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") + originAddr := common.HexToAddress("0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB") + + lbtEntries := []LBTEntry{ + { + WrappedTokenAddress: tokenAddr, + OriginNetwork: 0, + OriginTokenAddress: originAddr, + Balance: "1000000", + }, + } + + stepB := &StepBResult{ + Accumulated: []AccumulatedBalance{ + { + WrappedTokenAddress: tokenAddr, + OriginNetwork: 0, + OriginTokenAddress: originAddr, + TotalBalance: "600000", + }, + }, + } + + result, err := RunStepC(lbtEntries, stepB) + require.NoError(t, err) + require.Len(t, result.SCLockedValues, 1) + + scLocked, ok := new(big.Int).SetString(result.SCLockedValues[0].PendingSCLockedBalance, 10) + require.True(t, ok) + require.Equal(t, big.NewInt(400000), scLocked) +} + +func TestRunStepC_EOAExceedsLBT(t *testing.T) { + t.Parallel() + + tokenAddr := common.HexToAddress("0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") + originAddr := common.HexToAddress("0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB") + + lbtEntries := []LBTEntry{ + { + WrappedTokenAddress: tokenAddr, + OriginNetwork: 0, + OriginTokenAddress: originAddr, + Balance: "500000", + }, + } + + stepB := &StepBResult{ + Accumulated: []AccumulatedBalance{ + { + WrappedTokenAddress: tokenAddr, + OriginNetwork: 0, + OriginTokenAddress: originAddr, + TotalBalance: "800000", + }, + }, + } + + result, err := RunStepC(lbtEntries, stepB) + require.NoError(t, err) + require.Len(t, result.SCLockedValues, 1) + + // SC-locked should be clamped to 0 when EOA exceeds LBT + require.Equal(t, "0", result.SCLockedValues[0].PendingSCLockedBalance) +} + +func TestRunStepC_EmptyLBT(t *testing.T) { + t.Parallel() + + result, err := RunStepC([]LBTEntry{}, &StepBResult{Accumulated: nil}) + require.NoError(t, err) + require.Empty(t, result.SCLockedValues) +} + +func TestRunStepC_MultipleTokens(t *testing.T) { + t.Parallel() + + token1 := common.HexToAddress("0x1111111111111111111111111111111111111111") + token2 := common.HexToAddress("0x2222222222222222222222222222222222222222") + origin1 := common.HexToAddress("0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") + origin2 := common.HexToAddress("0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB") + + lbtEntries := []LBTEntry{ + {WrappedTokenAddress: token1, OriginNetwork: 0, OriginTokenAddress: origin1, Balance: "1000000"}, + {WrappedTokenAddress: token2, OriginNetwork: 1, OriginTokenAddress: origin2, Balance: "2000000"}, + } + + stepB := &StepBResult{ + Accumulated: []AccumulatedBalance{ + {WrappedTokenAddress: token1, OriginNetwork: 0, OriginTokenAddress: origin1, TotalBalance: "300000"}, + {WrappedTokenAddress: token2, OriginNetwork: 1, OriginTokenAddress: origin2, TotalBalance: "500000"}, + }, + } + + result, err := RunStepC(lbtEntries, stepB) + require.NoError(t, err) + require.Len(t, result.SCLockedValues, 2) + + scLockedMap := make(map[common.Address]string) + for _, v := range result.SCLockedValues { + scLockedMap[v.WrappedTokenAddress] = v.PendingSCLockedBalance + } + + require.Equal(t, "700000", scLockedMap[token1]) + require.Equal(t, "1500000", scLockedMap[token2]) +} + +// --- ERC20HolderBreakdown tests --- + +// fixture used by the breakdown tests: +// +// LBT[token] = 2000, EOA_accumulated[token] = 1000 +// → raw SC_locked = 1000 (the vault's holdings are inside this) +// vault holds 900 of token +func breakdownFixture() (tokenAddr, originAddr, vaultAddr, alice, bob common.Address, lbtEntries []LBTEntry, accumulated []AccumulatedBalance) { + tokenAddr = common.HexToAddress("0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") + originAddr = common.HexToAddress("0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB") + vaultAddr = common.HexToAddress("0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC") + alice = common.HexToAddress("0x1111111111111111111111111111111111111111") + bob = common.HexToAddress("0x2222222222222222222222222222222222222222") + + lbtEntries = []LBTEntry{ + {WrappedTokenAddress: tokenAddr, OriginNetwork: 0, OriginTokenAddress: originAddr, Balance: "2000"}, + } + accumulated = []AccumulatedBalance{ + {WrappedTokenAddress: tokenAddr, OriginNetwork: 0, OriginTokenAddress: originAddr, TotalBalance: "1000"}, + } + return +} + +func TestRunStepC_BreakdownCreatesHolderBridges(t *testing.T) { + t.Parallel() + + tokenAddr, originAddr, vaultAddr, alice, bob, lbtEntries, accumulated := breakdownFixture() + // vault holds 900, alice=400 + bob=500 = 900 → full coverage, no remainder + breakdowns := []ERC20HolderBreakdown{{ + Address: vaultAddr, + Holders: []ERC20Holder{ + {Address: alice, Balance: "400"}, + {Address: bob, Balance: "500"}, + }, + Detected: &DetectedERC20{ + WrappedTokenBalances: []WrappedTokenBalance{{ + Token: WrappedToken{WrappedTokenAddress: tokenAddr, OriginNetwork: 0, OriginTokenAddress: originAddr}, + Balance: "900", + }}, + }, + }} + + result, err := RunStepC(lbtEntries, &StepBResult{Accumulated: accumulated, ERC20HolderBreakdowns: breakdowns}) + require.NoError(t, err) + + // Two individual holder bridges (1:1 with holder balances) + require.Len(t, result.HolderBridges, 2) + holderMap := make(map[common.Address]string) + for _, hb := range result.HolderBridges { + require.Equal(t, vaultAddr, hb.VaultAddress) + require.Equal(t, tokenAddr, hb.WrappedTokenAddress) + holderMap[hb.HolderAddress] = hb.Amount + } + require.Equal(t, "400", holderMap[alice]) + require.Equal(t, "500", holderMap[bob]) + + // SC_locked = (2000 - 1000) - 900 = 100 (other contracts not in breakdown) + require.Len(t, result.SCLockedValues, 1) + require.Equal(t, "100", result.SCLockedValues[0].PendingSCLockedBalance) +} + +func TestRunStepC_UnattributedRemainderGoesToSCLocked(t *testing.T) { + t.Parallel() + + // alice=300 + bob=400 = 700 < 900 → remainder=200 unattributed + // Those 200 stay in SC_locked and flow to exitAddress — no error + tokenAddr, originAddr, vaultAddr, alice, bob, lbtEntries, accumulated := breakdownFixture() + breakdowns := []ERC20HolderBreakdown{{ + Address: vaultAddr, + Holders: []ERC20Holder{ + {Address: alice, Balance: "300"}, + {Address: bob, Balance: "400"}, + }, + Detected: &DetectedERC20{ + WrappedTokenBalances: []WrappedTokenBalance{{ + Token: WrappedToken{WrappedTokenAddress: tokenAddr, OriginNetwork: 0, OriginTokenAddress: originAddr}, + Balance: "900", + }}, + }, + }} + + result, err := RunStepC(lbtEntries, &StepBResult{Accumulated: accumulated, ERC20HolderBreakdowns: breakdowns}) + require.NoError(t, err) + + // Only known-holder bridges are created (700 total distributed) + require.Len(t, result.HolderBridges, 2) + + // SC_locked = (2000 - 1000) - 700 = 300 + // (200 of the vault's 900 are unattributed and remain as SC_locked) + require.Len(t, result.SCLockedValues, 1) + require.Equal(t, "300", result.SCLockedValues[0].PendingSCLockedBalance) +} + +func TestRunStepC_HolderBalancesExceedVaultHoldings_Error(t *testing.T) { + t.Parallel() + + // alice=300 + bob=400 = 700 > 500 (vault holds) → corrupt data → error + tokenAddr, originAddr, vaultAddr, alice, bob, lbtEntries, accumulated := breakdownFixture() + breakdowns := []ERC20HolderBreakdown{{ + Address: vaultAddr, + Holders: []ERC20Holder{ + {Address: alice, Balance: "300"}, + {Address: bob, Balance: "400"}, + }, + Detected: &DetectedERC20{ + WrappedTokenBalances: []WrappedTokenBalance{{ + Token: WrappedToken{WrappedTokenAddress: tokenAddr, OriginNetwork: 0, OriginTokenAddress: originAddr}, + Balance: "500", + }}, + }, + }} + + _, err := RunStepC(lbtEntries, &StepBResult{Accumulated: accumulated, ERC20HolderBreakdowns: breakdowns}) + require.Error(t, err) + require.Contains(t, err.Error(), vaultAddr.Hex()) + require.Contains(t, err.Error(), "exceeds vault holdings") +} + +func TestRunStepC_BreakdownWithoutDetected_Skipped(t *testing.T) { + t.Parallel() + + tokenAddr, originAddr, _, alice, _, lbtEntries, accumulated := breakdownFixture() + vaultAddr := common.HexToAddress("0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC") + + // Breakdown has no Detected → skipped entirely + breakdowns := []ERC20HolderBreakdown{{ + Address: vaultAddr, + Holders: []ERC20Holder{{Address: alice, Balance: "400"}}, + Detected: nil, + }} + + result, err := RunStepC(lbtEntries, &StepBResult{Accumulated: accumulated, ERC20HolderBreakdowns: breakdowns}) + require.NoError(t, err) + require.Empty(t, result.HolderBridges) + // SC_locked unchanged: 2000 - 1000 = 1000 + require.Len(t, result.SCLockedValues, 1) + require.Equal(t, "1000", result.SCLockedValues[0].PendingSCLockedBalance) + + _ = tokenAddr + _ = originAddr +} + +func TestRunStepC_TokenNotInLBT(t *testing.T) { + t.Parallel() + + token1 := common.HexToAddress("0x1111111111111111111111111111111111111111") + origin1 := common.HexToAddress("0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") + extraToken := common.HexToAddress("0x9999999999999999999999999999999999999999") + + lbtEntries := []LBTEntry{ + {WrappedTokenAddress: token1, OriginNetwork: 0, OriginTokenAddress: origin1, Balance: "1000000"}, + } + + stepB := &StepBResult{ + Accumulated: []AccumulatedBalance{ + {WrappedTokenAddress: token1, OriginNetwork: 0, OriginTokenAddress: origin1, TotalBalance: "300000"}, + {WrappedTokenAddress: extraToken, OriginNetwork: 2, OriginTokenAddress: common.Address{}, TotalBalance: "100000"}, + }, + } + + result, err := RunStepC(lbtEntries, stepB) + require.NoError(t, err) + // Only token1 is in LBT, so only 1 SC-locked entry + require.Len(t, result.SCLockedValues, 1) + require.Equal(t, "700000", result.SCLockedValues[0].PendingSCLockedBalance) +} diff --git a/tools/exit_certificate/step_check.go b/tools/exit_certificate/step_check.go new file mode 100644 index 000000000..f3f36ad34 --- /dev/null +++ b/tools/exit_certificate/step_check.go @@ -0,0 +1,313 @@ +package exit_certificate + +import ( + "context" + "fmt" + "os/exec" + "strings" + + "github.com/0xPolygon/cdk-contracts-tooling/contracts/aggchain-multisig/aggchainbase" + "github.com/0xPolygon/cdk-contracts-tooling/contracts/aggchain-multisig/agglayerbridgel2" + "github.com/0xPolygon/cdk-contracts-tooling/contracts/aggchain-multisig/polygonrollupmanagerpessimistic" + "github.com/agglayer/aggkit/log" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/ethclient" +) + +// aggchainTypePP is the [2]byte identifier for Pessimistic Proof mode in the aggchainbase contract. +var aggchainTypePP = [2]byte{0, 0} + +// RunStepCheck verifies prerequisites before running the pipeline: +// 1. Anvil is installed ($PATH). +// 2. L1 RPC is set and reachable. +// 3. L2 network ID matches the bridge contract. +// 4. sovereignRollupAddr is set. +// 5. Network type is PP (FEP is not supported). +// 6. Multisig threshold is 1. +// +// All checks run regardless of individual failures. Returns a combined error listing every +// failed check. +func RunStepCheck(ctx context.Context, cfg *Config) (*StepCheckResult, error) { + log.Info("═══════════════════════════════════════════") + log.Info(" STEP CHECK — Verify prerequisites") + log.Info("═══════════════════════════════════════════") + + result := &StepCheckResult{} + var failures []string + + // --- 1. Anvil --- + if _, err := exec.LookPath("anvil"); err != nil { + result.AnvilInstalled = false + log.Info("❌ anvil not found in $PATH — install the Foundry toolchain from https://getfoundry.sh") + failures = append(failures, "anvil not found in $PATH (install from https://getfoundry.sh)") + } else { + result.AnvilInstalled = true + log.Info("✅ anvil is installed") + } + + // --- 2. L1 RPC reachable --- + var l1Client *ethclient.Client + if cfg.L1RPCURL == "" { + log.Info("❌ l1RpcUrl is not set") + failures = append(failures, "l1RpcUrl is required") + } else { + var err error + l1Client, err = ethclient.DialContext(ctx, cfg.L1RPCURL) + if err != nil { + msg := fmt.Sprintf("l1RpcUrl unreachable (%s): %v", cfg.L1RPCURL, err) + log.Infof("❌ %s", msg) + failures = append(failures, msg) + } else { + if _, err := l1Client.BlockNumber(ctx); err != nil { + l1Client.Close() + l1Client = nil + msg := fmt.Sprintf("l1RpcUrl not responding (%s): %v", cfg.L1RPCURL, err) + log.Infof("❌ %s", msg) + failures = append(failures, msg) + } else { + log.Infof("✅ l1RpcUrl is reachable (%s)", cfg.L1RPCURL) + } + } + } + if l1Client != nil { + defer l1Client.Close() + } + + // --- 3. L2 network ID matches bridge contract --- + checkL2NetworkID(ctx, cfg, result, &failures) + + // --- 4. sovereignRollupAddr is set --- + zeroAddr := [20]byte{} + if cfg.SovereignRollupAddr == zeroAddr { + log.Info("❌ sovereignRollupAddr is not set — required to verify network type and threshold") + msg := "sovereignRollupAddr is required (set it in the config to verify the network is PP with threshold=1)" + failures = append(failures, msg) + result.NetworkType = uncheckedStatus + } else if l1Client != nil { + // --- 5 & 6. Network type + threshold --- + checkContractPrereqs(ctx, cfg, l1Client, result, &failures) + } else { + // L1 client failed — contract checks cannot run + result.NetworkType = uncheckedStatus + log.Info("❌ network type and threshold checks skipped — l1RpcUrl is not available") + failures = append(failures, "network type and threshold could not be verified (l1RpcUrl unavailable)") + } + checkNativeGasToken(ctx, cfg, &failures) + + log.Info("───────────────────────────────────────────") + if len(failures) == 0 { + log.Info("✅ All checks passed") + log.Info("STEP CHECK complete") + return result, nil + } + + log.Infof("❌ %d check(s) failed", len(failures)) + log.Info("STEP CHECK failed") + return result, fmt.Errorf("prerequisite checks failed:\n - %s", strings.Join(failures, "\n - ")) +} + +// checkL2NetworkID dials the L2 RPC, calls NetworkID() on the bridge contract, and verifies +// it matches cfg.L2NetworkID. +func checkL2NetworkID(ctx context.Context, cfg *Config, result *StepCheckResult, failures *[]string) { + l2Client, err := ethclient.DialContext(ctx, cfg.L2RPCURL) + if err != nil { + msg := fmt.Sprintf("dial L2 RPC (%s): %v", cfg.L2RPCURL, err) + log.Infof("❌ %s", msg) + *failures = append(*failures, msg) + return + } + defer l2Client.Close() + + caller, err := agglayerbridgel2.NewAgglayerbridgel2Caller(cfg.L2BridgeAddress, l2Client) + if err != nil { + msg := fmt.Sprintf("create bridge caller (addr=%s): %v", cfg.L2BridgeAddress.Hex(), err) + log.Infof("❌ %s", msg) + *failures = append(*failures, msg) + return + } + + onChainID, err := caller.NetworkID(&bind.CallOpts{Context: ctx}) + if err != nil { + msg := fmt.Sprintf("query bridge NetworkID(): %v", err) + log.Infof("❌ %s", msg) + *failures = append(*failures, msg) + return + } + + result.BridgeNetworkID = onChainID + if onChainID == cfg.L2NetworkID { + log.Infof("✅ l2NetworkId matches bridge contract (%d)", cfg.L2NetworkID) + } else { + msg := fmt.Sprintf("l2NetworkId mismatch: config=%d, bridge contract=%d", cfg.L2NetworkID, onChainID) + log.Infof("❌ %s", msg) + *failures = append(*failures, msg) + } +} + +func checkNativeGasToken(ctx context.Context, cfg *Config, failures *[]string) { + gasTokenNetwork, gasTokenAddr, err := fetchGasTokenInfo(ctx, cfg.L2RPCURL, cfg.L2BridgeAddress) + if err != nil { + msg := fmt.Sprintf("fetch bridge gas token info: %v", err) + log.Infof("❌ %s", msg) + *failures = append(*failures, msg) + return + } + if gasTokenAddr != (common.Address{}) { + msg := fmt.Sprintf("Bridge gas token not supported: network=%d, address=%s", gasTokenNetwork, gasTokenAddr.Hex()) + log.Infof("❌ %s", msg) + *failures = append(*failures, msg) + } else { + log.Infof("✅ No bridge gas token: network=%d, address=%s", gasTokenNetwork, gasTokenAddr.Hex()) + } +} + +// checkContractPrereqs queries the aggchainbase contract for network type and threshold. +// l1Client is already dialed and verified reachable by the caller. +func checkContractPrereqs( + ctx context.Context, cfg *Config, l1Client *ethclient.Client, result *StepCheckResult, failures *[]string, +) { + caller, err := aggchainbase.NewAggchainbaseCaller(cfg.SovereignRollupAddr, l1Client) + if err != nil { + msg := fmt.Sprintf("create aggchainbase caller (addr=%s): %v", cfg.SovereignRollupAddr.Hex(), err) + log.Infof("❌ %s", msg) + *failures = append(*failures, msg) + result.NetworkType = uncheckedStatus + return + } + + callOpts := &bind.CallOpts{Context: ctx} + + // --- 5. Network type --- + aggchainType, err := caller.AGGCHAINTYPE(callOpts) + if err != nil { + msg := fmt.Sprintf("query AGGCHAINTYPE: %v", err) + log.Infof("❌ %s", msg) + *failures = append(*failures, msg) + result.NetworkType = "unknown" + log.Info(" (AGGCHAINTYPE unavailable — contract may be pre-aggchainbase;" + + " attempting legacy rollup manager diagnostics)") + logLegacyRollupInfo(ctx, caller, cfg.SovereignRollupAddr, l1Client) + } else if aggchainType == aggchainTypePP { + result.NetworkType = "PP" + log.Info("✅ network type is Pessimistic Proof (PP) — supported") + } else { + result.NetworkType = "FEP" + msg := fmt.Sprintf("network type is FEP (AGGCHAINTYPE=%v) — only Pessimistic Proof (PP) is supported", aggchainType) + log.Infof("❌ %s", msg) + *failures = append(*failures, msg) + } + + // --- 6. Threshold --- + threshold, err := caller.Threshold(callOpts) + if err != nil { + msg := fmt.Sprintf("query Threshold: %v", err) + log.Infof("❌ %s", msg) + *failures = append(*failures, msg) + return + } + + signers, err := caller.GetAggchainSignerInfos(callOpts) + if err != nil { + msg := fmt.Sprintf("query GetAggchainSignerInfos: %v", err) + log.Infof("❌ %s", msg) + *failures = append(*failures, msg) + return + } + + result.Threshold = threshold.Uint64() + result.SignerCount = len(signers) + for _, s := range signers { + result.Signers = append(result.Signers, s.Addr.Hex()) + } + + log.Infof(" Multisig committee: threshold=%d of %d", result.Threshold, result.SignerCount) + for i, s := range signers { + log.Infof(" Signer[%d]: addr=%s url=%s", i, s.Addr.Hex(), s.Url) + } + + if result.Threshold == 1 { + log.Info("✅ threshold is 1 — supported") + } else { + msg := fmt.Sprintf( + "multisig threshold is %d — this tool produces only 1 signature, agglayer will reject the certificate", + result.Threshold, + ) + log.Infof("❌ %s", msg) + *failures = append(*failures, msg) + } + bridgeAddr, err := caller.BridgeAddress(callOpts) + if err != nil { + msg := fmt.Sprintf("query BridgeAddress: %v", err) + log.Infof("❌ %s", msg) + *failures = append(*failures, msg) + } else { + if bridgeAddr != cfg.L2BridgeAddress { + msg := fmt.Sprintf("bridge address mismatch: bridge contract=%s, config=%s", + bridgeAddr.Hex(), cfg.L2BridgeAddress.Hex()) + log.Infof("❌ %s", msg) + *failures = append(*failures, msg) + } else { + log.Infof("✅ bridge address from aggchainbase matches config (%s)", bridgeAddr.Hex()) + } + } + + rollupManager, err := caller.RollupManager(callOpts) + if err != nil { + msg := fmt.Sprintf("query RollupManager: %v", err) + log.Infof("❌ %s", msg) + *failures = append(*failures, msg) + } else { + log.Infof(" RollupManager address: %s", rollupManager.Hex()) + } +} + +// logLegacyRollupInfo gathers rollup manager diagnostics when AGGCHAINTYPE is unavailable +// (pre-aggchainbase contracts). It does not modify check results or failures — it only logs. +func logLegacyRollupInfo( + ctx context.Context, + caller *aggchainbase.AggchainbaseCaller, + sovereignRollupAddr common.Address, + l1Client *ethclient.Client, +) { + callOpts := &bind.CallOpts{Context: ctx} + + rollupManagerAddr, err := caller.RollupManager(callOpts) + if err != nil { + log.Infof(" (legacy diagnostics) RollupManager() failed: %v", err) + return + } + log.Infof(" (legacy diagnostics) RollupManager: %s", rollupManagerAddr.Hex()) + + rmCaller, err := polygonrollupmanagerpessimistic.NewPolygonrollupmanagerpessimisticCaller(rollupManagerAddr, l1Client) + if err != nil { + log.Infof(" (legacy diagnostics) create rollup manager caller: %v", err) + return + } + + rollupID, err := rmCaller.RollupAddressToID(callOpts, sovereignRollupAddr) + if err != nil { + log.Infof(" (legacy diagnostics) RollupAddressToID(%s): %v", sovereignRollupAddr.Hex(), err) + return + } + log.Infof(" (legacy diagnostics) rollupID: %d", rollupID) + + rollupData, err := rmCaller.RollupIDToRollupData(callOpts, rollupID) + if err != nil { + log.Infof(" (legacy diagnostics) RollupIDToRollupData(%d): %v", rollupID, err) + return + } + log.Infof(" (legacy diagnostics) rollupTypeID: %d chainID: %d forkID: %d rollupVerifierType: %d", + rollupData.RollupTypeID, rollupData.ChainID, rollupData.ForkID, rollupData.RollupVerifierType) + + typeInfo, err := rmCaller.RollupTypeMap(callOpts, uint32(rollupData.RollupTypeID)) + if err != nil { + log.Infof(" (legacy diagnostics) RollupTypeMap(%d): %v", rollupData.RollupTypeID, err) + return + } + log.Infof(" (legacy diagnostics) rollupType: consensusImpl=%s verifier=%s forkID=%d verifierType=%d obsolete=%v", + typeInfo.ConsensusImplementation.Hex(), typeInfo.Verifier.Hex(), + typeInfo.ForkID, typeInfo.RollupVerifierType, typeInfo.Obsolete) + log.Infof(" (legacy diagnostics) rollupType: genesis=%x programVKey=%x", + typeInfo.Genesis, typeInfo.ProgramVKey) +} diff --git a/tools/exit_certificate/step_check_test.go b/tools/exit_certificate/step_check_test.go new file mode 100644 index 000000000..f5dc0d546 --- /dev/null +++ b/tools/exit_certificate/step_check_test.go @@ -0,0 +1,278 @@ +package exit_certificate + +import ( + "context" + "encoding/json" + "io" + "math/big" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/0xPolygon/cdk-contracts-tooling/contracts/aggchain-multisig/aggchainbase" + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/stretchr/testify/require" +) + +// --- contract-call stub --------------------------------------------------------------------------- + +// aggchainbaseABI is the parsed aggchainbase ABI used to compute selectors and pack return values for +// the stub (the bridge ABI is the package-level bridgeABI, parsed in step_g2.go's init). +var aggchainbaseABI = func() abi.ABI { + a, err := aggchainbase.AggchainbaseMetaData.GetAbi() + if err != nil { + panic(err) + } + return *a +}() + +// selectorHex returns the 4-byte method selector (hex, no 0x) for a method on the given ABI. +func selectorHex(a abi.ABI, method string) string { + return common.Bytes2Hex(a.Methods[method].ID) +} + +// packReturn ABI-encodes a method's return values (hex, no 0x) as the contract would. +func packReturn(t *testing.T, a abi.ABI, method string, vals ...any) string { + t.Helper() + b, err := a.Methods[method].Outputs.Pack(vals...) + require.NoError(t, err) + return common.Bytes2Hex(b) +} + +// newContractStub serves eth_call by dispatching on the 4-byte selector: returns[selectorHex] is the +// hex-encoded return data. A selector that is absent gets a JSON-RPC error so failure paths can be +// exercised. It also answers eth_blockNumber/eth_chainId so ethclient dials and reachability checks +// succeed. +func newContractStub(t *testing.T, returns map[string]string) string { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + var req struct { + ID json.RawMessage `json:"id"` + Method string `json:"method"` + Params []json.RawMessage `json:"params"` + } + _ = json.Unmarshal(body, &req) + resp := map[string]any{"jsonrpc": "2.0", "id": req.ID} + + switch req.Method { + case "eth_blockNumber", "eth_chainId": + resp["result"] = "0x1" + case "eth_call": + var call struct { + Data string `json:"data"` + Input string `json:"input"` + } + _ = json.Unmarshal(req.Params[0], &call) + callData := call.Input // go-ethereum uses "input"; fall back to "data" + if callData == "" { + callData = call.Data + } + sel := strings.TrimPrefix(callData, "0x") + if len(sel) >= 8 { + sel = sel[:8] + } + if out, ok := returns[sel]; ok { + resp["result"] = "0x" + out + } else { + resp["error"] = map[string]any{"code": -32000, "message": "execution reverted"} + } + default: + resp["result"] = "0x" + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + })) + t.Cleanup(srv.Close) + return srv.URL +} + +// --- checkL2NetworkID ---------------------------------------------------------------------------- + +func TestCheckL2NetworkIDMatch(t *testing.T) { + t.Parallel() + url := newContractStub(t, map[string]string{ + selectorHex(bridgeABI, "networkID"): packReturn(t, bridgeABI, "networkID", uint32(7)), + }) + cfg := &Config{L2RPCURL: url, L2BridgeAddress: common.HexToAddress("0xbridge"), L2NetworkID: 7} + result := &StepCheckResult{} + var failures []string + checkL2NetworkID(context.Background(), cfg, result, &failures) + require.Empty(t, failures) + require.Equal(t, uint32(7), result.BridgeNetworkID) +} + +func TestCheckL2NetworkIDMismatch(t *testing.T) { + t.Parallel() + url := newContractStub(t, map[string]string{ + selectorHex(bridgeABI, "networkID"): packReturn(t, bridgeABI, "networkID", uint32(99)), + }) + cfg := &Config{L2RPCURL: url, L2NetworkID: 7} + result := &StepCheckResult{} + var failures []string + checkL2NetworkID(context.Background(), cfg, result, &failures) + require.Len(t, failures, 1) + require.Contains(t, failures[0], "mismatch") +} + +func TestCheckL2NetworkIDCallError(t *testing.T) { + t.Parallel() + // no networkID selector registered → eth_call errors + url := newContractStub(t, map[string]string{}) + cfg := &Config{L2RPCURL: url, L2NetworkID: 7} + result := &StepCheckResult{} + var failures []string + checkL2NetworkID(context.Background(), cfg, result, &failures) + require.Len(t, failures, 1) + require.Contains(t, failures[0], "NetworkID") +} + +// --- checkNativeGasToken ------------------------------------------------------------------------- + +func TestCheckNativeGasTokenNone(t *testing.T) { + t.Parallel() + url := newContractStub(t, map[string]string{ + selectorHex(bridgeABI, "gasTokenNetwork"): packReturn(t, bridgeABI, "gasTokenNetwork", uint32(0)), + selectorHex(bridgeABI, "gasTokenAddress"): packReturn(t, bridgeABI, "gasTokenAddress", common.Address{}), + }) + cfg := &Config{L2RPCURL: url} + var failures []string + checkNativeGasToken(context.Background(), cfg, &failures) + require.Empty(t, failures) +} + +func TestCheckNativeGasTokenPresent(t *testing.T) { + t.Parallel() + url := newContractStub(t, map[string]string{ + selectorHex(bridgeABI, "gasTokenNetwork"): packReturn(t, bridgeABI, "gasTokenNetwork", uint32(1)), + selectorHex(bridgeABI, "gasTokenAddress"): packReturn(t, bridgeABI, "gasTokenAddress", common.HexToAddress("0xdead")), + }) + cfg := &Config{L2RPCURL: url} + var failures []string + checkNativeGasToken(context.Background(), cfg, &failures) + require.Len(t, failures, 1) + require.Contains(t, failures[0], "gas token not supported") +} + +func TestCheckNativeGasTokenError(t *testing.T) { + t.Parallel() + url := newContractStub(t, map[string]string{}) // gasToken selectors absent → call errors + cfg := &Config{L2RPCURL: url} + var failures []string + checkNativeGasToken(context.Background(), cfg, &failures) + require.Len(t, failures, 1) +} + +// --- checkContractPrereqs ------------------------------------------------------------------------ + +func contractPrereqReturns(t *testing.T, bridgeAddr common.Address, aggchainType [2]byte, threshold int64) map[string]string { + t.Helper() + return map[string]string{ + selectorHex(aggchainbaseABI, "AGGCHAIN_TYPE"): packReturn(t, aggchainbaseABI, "AGGCHAIN_TYPE", aggchainType), + selectorHex(aggchainbaseABI, "threshold"): packReturn(t, aggchainbaseABI, "threshold", big.NewInt(threshold)), + selectorHex(aggchainbaseABI, "getAggchainSignerInfos"): packReturn(t, aggchainbaseABI, "getAggchainSignerInfos", + []aggchainbase.IAggchainSignersSignerInfo{}), + selectorHex(aggchainbaseABI, "bridgeAddress"): packReturn(t, aggchainbaseABI, "bridgeAddress", bridgeAddr), + selectorHex(aggchainbaseABI, "rollupManager"): packReturn(t, aggchainbaseABI, "rollupManager", common.HexToAddress("0xr0")), + } +} + +func dialStub(t *testing.T, url string) *ethclient.Client { + t.Helper() + c, err := ethclient.DialContext(context.Background(), url) + require.NoError(t, err) + t.Cleanup(c.Close) + return c +} + +func TestCheckContractPrereqsPP(t *testing.T) { + t.Parallel() + bridgeAddr := common.BytesToAddress([]byte("bridge")) + url := newContractStub(t, contractPrereqReturns(t, bridgeAddr, [2]byte{0, 0}, 1)) + cfg := &Config{SovereignRollupAddr: common.BytesToAddress([]byte("sov")), L2BridgeAddress: bridgeAddr} + result := &StepCheckResult{} + var failures []string + checkContractPrereqs(context.Background(), cfg, dialStub(t, url), result, &failures) + require.Empty(t, failures) + require.Equal(t, "PP", result.NetworkType) + require.Equal(t, uint64(1), result.Threshold) +} + +func TestCheckContractPrereqsFEPThresholdAndBridgeMismatch(t *testing.T) { + t.Parallel() + url := newContractStub(t, contractPrereqReturns(t, common.BytesToAddress([]byte("other")), [2]byte{0, 1}, 2)) + cfg := &Config{SovereignRollupAddr: common.BytesToAddress([]byte("sov")), L2BridgeAddress: common.BytesToAddress([]byte("bridge"))} + result := &StepCheckResult{} + var failures []string + checkContractPrereqs(context.Background(), cfg, dialStub(t, url), result, &failures) + + require.Equal(t, "FEP", result.NetworkType) + joined := strings.Join(failures, "\n") + require.Contains(t, joined, "FEP") + require.Contains(t, joined, "threshold is 2") + require.Contains(t, joined, "bridge address mismatch") +} + +func TestCheckContractPrereqsAggchainTypeErrorTriggersLegacy(t *testing.T) { + t.Parallel() + // AGGCHAIN_TYPE selector omitted → its call errors, driving the legacy-diagnostics branch. + rets := contractPrereqReturns(t, common.HexToAddress("0xbridge"), [2]byte{0, 0}, 1) + delete(rets, selectorHex(aggchainbaseABI, "AGGCHAIN_TYPE")) + url := newContractStub(t, rets) + cfg := &Config{SovereignRollupAddr: common.HexToAddress("0xsov"), L2BridgeAddress: common.HexToAddress("0xbridge")} + result := &StepCheckResult{} + var failures []string + checkContractPrereqs(context.Background(), cfg, dialStub(t, url), result, &failures) + require.Equal(t, "unknown", result.NetworkType) + // threshold/bridge still resolved fine, so the only failure is the AGGCHAINTYPE query + require.Contains(t, strings.Join(failures, "\n"), "AGGCHAINTYPE") +} + +// --- RunStepCheck (failure aggregation) ---------------------------------------------------------- + +func TestRunStepCheckMissingL1AndSovereign(t *testing.T) { + t.Parallel() + // L2 stub answers networkID + gas token so those checks pass; L1 unset and sovereign unset fail. + url := newContractStub(t, map[string]string{ + selectorHex(bridgeABI, "networkID"): packReturn(t, bridgeABI, "networkID", uint32(1)), + selectorHex(bridgeABI, "gasTokenNetwork"): packReturn(t, bridgeABI, "gasTokenNetwork", uint32(0)), + selectorHex(bridgeABI, "gasTokenAddress"): packReturn(t, bridgeABI, "gasTokenAddress", common.Address{}), + }) + cfg := &Config{L2RPCURL: url, L2NetworkID: 1} // L1RPCURL and SovereignRollupAddr left zero + + result, err := RunStepCheck(context.Background(), cfg) + require.Error(t, err) + require.Equal(t, uncheckedStatus, result.NetworkType) + require.Contains(t, err.Error(), "l1RpcUrl is required") + require.Contains(t, err.Error(), "sovereignRollupAddr is required") +} + +func TestRunStepCheckAllReachable(t *testing.T) { + t.Parallel() + bridgeAddr := common.BytesToAddress([]byte("bridge")) + rets := contractPrereqReturns(t, bridgeAddr, [2]byte{0, 0}, 1) + rets[selectorHex(bridgeABI, "networkID")] = packReturn(t, bridgeABI, "networkID", uint32(1)) + rets[selectorHex(bridgeABI, "gasTokenNetwork")] = packReturn(t, bridgeABI, "gasTokenNetwork", uint32(0)) + rets[selectorHex(bridgeABI, "gasTokenAddress")] = packReturn(t, bridgeABI, "gasTokenAddress", common.Address{}) + url := newContractStub(t, rets) + + // One stub backs both L1 and L2 (it dispatches purely on selector). + cfg := &Config{ + L1RPCURL: url, L2RPCURL: url, L2NetworkID: 1, + L2BridgeAddress: bridgeAddr, SovereignRollupAddr: common.BytesToAddress([]byte("sov")), + } + + result, err := RunStepCheck(context.Background(), cfg) + // anvil presence is environment-dependent: the only acceptable failure is the anvil check. + if err != nil { + require.Contains(t, err.Error(), "anvil") + require.NotContains(t, err.Error(), "l1RpcUrl") + require.NotContains(t, err.Error(), "NetworkID") + } + require.Equal(t, "PP", result.NetworkType) + require.Equal(t, uint32(1), result.BridgeNetworkID) + require.Equal(t, uint64(1), result.Threshold) +} diff --git a/tools/exit_certificate/step_d.go b/tools/exit_certificate/step_d.go new file mode 100644 index 000000000..2c8604555 --- /dev/null +++ b/tools/exit_certificate/step_d.go @@ -0,0 +1,133 @@ +package exit_certificate + +import ( + "math/big" + + agglayertypes "github.com/agglayer/aggkit/agglayer/types" + bridgetypes "github.com/agglayer/aggkit/bridgesync/types" + "github.com/agglayer/aggkit/log" + "github.com/ethereum/go-ethereum/common" +) + +// RunStepD builds the exit certificate from EOA balances (Step B) and SC-locked values (Step C). +// +// Creates BridgeExit entries for: +// 1. Every (EOA, token) pair with a non-zero balance +// 2. Every holder of an ERC-20 vault/staking contract (from Step C HolderBridges) +// 3. Every token with remaining SC-locked value, directed to exitAddress +func RunStepD(cfg *Config, stepB *StepBResult, stepC *StepCResult) (*StepDResult, error) { + log.Info("═══════════════════════════════════════════") + log.Info(" STEP D — Build exit certificate") + log.Info("═══════════════════════════════════════════") + + destNetwork := cfg.DestinationNetwork + exitAddr := cfg.ExitAddress + + eoaExits := buildEOAExits(stepB, destNetwork) + log.Infof("EOA exits: %d", len(eoaExits)) + + holderExits := buildHolderBridgeExits(stepC, destNetwork) + log.Infof("Holder exits: %d", len(holderExits)) + + scExits := buildSCLockedExits(stepC, destNetwork, exitAddr) + log.Infof("SC-locked exits: %d", len(scExits)) + + bridgeExits := make([]*agglayertypes.BridgeExit, 0, len(eoaExits)+len(holderExits)+len(scExits)) + bridgeExits = append(bridgeExits, eoaExits...) + bridgeExits = append(bridgeExits, holderExits...) + bridgeExits = append(bridgeExits, scExits...) + + certificate := &agglayertypes.Certificate{ + NetworkID: cfg.L2NetworkID, + PrevLocalExitRoot: common.Hash{}, + NewLocalExitRoot: common.Hash{}, + BridgeExits: bridgeExits, + } + + log.Infof("STEP D complete: certificate has %d bridge exits (%d EOA + %d holder + %d SC-locked)", + len(bridgeExits), len(eoaExits), len(holderExits), len(scExits)) + + return &StepDResult{Certificate: certificate}, nil +} + +func buildEOAExits(stepB *StepBResult, destNetwork uint32) []*agglayertypes.BridgeExit { + totalEOAs := len(stepB.EOABalances) + log.Infof("Processing %d EOA balance entries...", totalEOAs) + + logInterval := max(totalEOAs/logGranularity, 1) + exits := make([]*agglayertypes.BridgeExit, 0, len(stepB.EOABalances)) + for i, eoa := range stepB.EOABalances { + if totalEOAs > 0 && (i+1)%logInterval == 0 { + log.Infof(" EOA progress: %d/%d", i+1, totalEOAs) + } + exits = append(exits, eoaToExits(eoa, destNetwork)...) + } + return exits +} + +func eoaToExits(eoa EOABalance, destNetwork uint32) []*agglayertypes.BridgeExit { + var exits []*agglayertypes.BridgeExit + if amount := parseDecimalBigInt(eoa.ETHBalance); amount.Sign() > 0 { + exits = append(exits, makeBridgeExit(0, common.Address{}, destNetwork, eoa.Address, amount)) + } + for _, token := range eoa.Tokens { + if amount := parseDecimalBigInt(token.Balance); amount.Sign() > 0 { + exits = append(exits, makeBridgeExit( + token.OriginNetwork, token.OriginTokenAddress, destNetwork, eoa.Address, amount, + )) + } + } + return exits +} + +func buildHolderBridgeExits(stepC *StepCResult, destNetwork uint32) []*agglayertypes.BridgeExit { + exits := make([]*agglayertypes.BridgeExit, 0, len(stepC.HolderBridges)) + for _, hb := range stepC.HolderBridges { + amount := parseDecimalBigInt(hb.Amount) + if amount.Sign() <= 0 { + continue + } + exits = append(exits, makeBridgeExit(hb.OriginNetwork, hb.OriginTokenAddress, destNetwork, hb.HolderAddress, amount)) + } + return exits +} + +func buildSCLockedExits( + stepC *StepCResult, destNetwork uint32, exitAddr common.Address, +) []*agglayertypes.BridgeExit { + log.Infof("Processing SC-locked values → exit address: %s", exitAddr.Hex()) + + exits := make([]*agglayertypes.BridgeExit, 0, len(stepC.SCLockedValues)) + for _, entry := range stepC.SCLockedValues { + amount := parseDecimalBigInt(entry.PendingSCLockedBalance) + if amount.Sign() <= 0 { + continue + } + exits = append(exits, makeBridgeExit(entry.OriginNetwork, entry.OriginTokenAddress, destNetwork, exitAddr, amount)) + } + return exits +} + +// MakeBridgeExit creates a BridgeExit for an asset transfer. Exported for tests. +func MakeBridgeExit( + originNetwork uint32, originTokenAddress common.Address, + destNetwork uint32, destAddress common.Address, amount *big.Int, +) *agglayertypes.BridgeExit { + return makeBridgeExit(originNetwork, originTokenAddress, destNetwork, destAddress, amount) +} + +func makeBridgeExit( + originNetwork uint32, originTokenAddress common.Address, + destNetwork uint32, destAddress common.Address, amount *big.Int, +) *agglayertypes.BridgeExit { + return &agglayertypes.BridgeExit{ + LeafType: bridgetypes.LeafTypeAsset, + TokenInfo: &agglayertypes.TokenInfo{ + OriginNetwork: originNetwork, + OriginTokenAddress: originTokenAddress, + }, + DestinationNetwork: destNetwork, + DestinationAddress: destAddress, + Amount: amount, + } +} diff --git a/tools/exit_certificate/step_d_test.go b/tools/exit_certificate/step_d_test.go new file mode 100644 index 000000000..4a0465151 --- /dev/null +++ b/tools/exit_certificate/step_d_test.go @@ -0,0 +1,188 @@ +package exit_certificate + +import ( + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +func TestRunStepD_EOABalancesOnly(t *testing.T) { + t.Parallel() + + addr1 := common.HexToAddress("0x1111111111111111111111111111111111111111") + addr2 := common.HexToAddress("0x2222222222222222222222222222222222222222") + + cfg := &Config{ + L2NetworkID: 1, + ExitAddress: common.HexToAddress("0x0000000000000000000000000000000000000001"), + DestinationNetwork: 0, + } + + stepB := &StepBResult{ + EOABalances: []EOABalance{ + { + Address: addr1, + ETHBalance: "1000000000000000000", + Tokens: []EOATokenBalance{ + { + WrappedTokenAddress: common.HexToAddress("0xAAAA"), + OriginNetwork: 0, + OriginTokenAddress: common.HexToAddress("0xBBBB"), + Balance: "5000000", + }, + }, + }, + { + Address: addr2, + ETHBalance: "2000000000000000000", + }, + }, + } + + stepC := &StepCResult{SCLockedValues: nil} + + result, err := RunStepD(cfg, stepB, stepC) + require.NoError(t, err) + require.NotNil(t, result.Certificate) + require.Equal(t, uint32(1), result.Certificate.NetworkID) + + // addr1: ETH + 1 token = 2 exits, addr2: ETH = 1 exit → total 3 + require.Len(t, result.Certificate.BridgeExits, 3) + + // Verify first exit is addr1 ETH + exit0 := result.Certificate.BridgeExits[0] + require.Equal(t, addr1, exit0.DestinationAddress) + require.Equal(t, uint32(0), exit0.DestinationNetwork) + expectedETH, _ := new(big.Int).SetString("1000000000000000000", 10) + require.Equal(t, expectedETH, exit0.Amount) + + // Verify second exit is addr1 token + exit1 := result.Certificate.BridgeExits[1] + require.Equal(t, addr1, exit1.DestinationAddress) + require.Equal(t, big.NewInt(5000000), exit1.Amount) + + // Verify third exit is addr2 ETH + exit2 := result.Certificate.BridgeExits[2] + require.Equal(t, addr2, exit2.DestinationAddress) +} + +func TestRunStepD_WithSCLockedValues(t *testing.T) { + t.Parallel() + + exitAddr := common.HexToAddress("0x0000000000000000000000000000000000000001") + tokenOriginAddr := common.HexToAddress("0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") + + cfg := &Config{ + L2NetworkID: 2, + ExitAddress: exitAddr, + DestinationNetwork: 0, + } + + stepB := &StepBResult{EOABalances: nil} + stepC := &StepCResult{ + SCLockedValues: []SCLockedValue{ + { + WrappedTokenAddress: common.HexToAddress("0xBBBB"), + OriginNetwork: 0, + OriginTokenAddress: tokenOriginAddr, + LBTBalance: "1000000", + EOAAccumulated: "300000", + PendingSCLockedBalance: "700000", + }, + { + WrappedTokenAddress: common.HexToAddress("0xCCCC"), + OriginNetwork: 1, + OriginTokenAddress: common.HexToAddress("0xDDDD"), + LBTBalance: "500000", + EOAAccumulated: "500000", + PendingSCLockedBalance: "0", + }, + }, + } + + result, err := RunStepD(cfg, stepB, stepC) + require.NoError(t, err) + + // Only 1 SC-locked exit (the second has balance 0) + require.Len(t, result.Certificate.BridgeExits, 1) + + exit := result.Certificate.BridgeExits[0] + require.Equal(t, exitAddr, exit.DestinationAddress) + require.Equal(t, big.NewInt(700000), exit.Amount) + require.Equal(t, tokenOriginAddr, exit.TokenInfo.OriginTokenAddress) +} + +func TestRunStepD_EmptyInputs(t *testing.T) { + t.Parallel() + + cfg := &Config{ + L2NetworkID: 1, + DestinationNetwork: 0, + } + + result, err := RunStepD(cfg, &StepBResult{}, &StepCResult{}) + require.NoError(t, err) + require.NotNil(t, result.Certificate) + require.Empty(t, result.Certificate.BridgeExits) +} + +func TestMakeBridgeExit(t *testing.T) { + t.Parallel() + + origin := common.HexToAddress("0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") + dest := common.HexToAddress("0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB") + amount := big.NewInt(12345) + + exit := MakeBridgeExit(1, origin, 0, dest, amount) + + require.Equal(t, uint8(0), exit.LeafType.Uint8()) + require.NotNil(t, exit.TokenInfo) + require.Equal(t, uint32(1), exit.TokenInfo.OriginNetwork) + require.Equal(t, origin, exit.TokenInfo.OriginTokenAddress) + require.Equal(t, uint32(0), exit.DestinationNetwork) + require.Equal(t, dest, exit.DestinationAddress) + require.Equal(t, amount, exit.Amount) +} + +func TestRunStepD_CombinedEOAAndSCLocked(t *testing.T) { + t.Parallel() + + addr1 := common.HexToAddress("0x1111111111111111111111111111111111111111") + exitAddr := common.HexToAddress("0x0000000000000000000000000000000000000001") + + cfg := &Config{ + L2NetworkID: 1, + ExitAddress: exitAddr, + DestinationNetwork: 0, + } + + stepB := &StepBResult{ + EOABalances: []EOABalance{ + {Address: addr1, ETHBalance: "1000000"}, + }, + } + + stepC := &StepCResult{ + SCLockedValues: []SCLockedValue{ + { + WrappedTokenAddress: common.HexToAddress("0xAAAA"), + OriginNetwork: 0, + OriginTokenAddress: common.HexToAddress("0xBBBB"), + PendingSCLockedBalance: "500000", + }, + }, + } + + result, err := RunStepD(cfg, stepB, stepC) + require.NoError(t, err) + + // 1 EOA exit + 1 SC-locked exit = 2 + require.Len(t, result.Certificate.BridgeExits, 2) + + // First exit is EOA's ETH + require.Equal(t, addr1, result.Certificate.BridgeExits[0].DestinationAddress) + // Second exit is SC-locked to exitAddr + require.Equal(t, exitAddr, result.Certificate.BridgeExits[1].DestinationAddress) +} diff --git a/tools/exit_certificate/step_e.go b/tools/exit_certificate/step_e.go new file mode 100644 index 000000000..db2eaa1ca --- /dev/null +++ b/tools/exit_certificate/step_e.go @@ -0,0 +1,789 @@ +package exit_certificate + +import ( + "context" + "encoding/json" + "fmt" + "math/big" + "sort" + "strconv" + "strings" + + agglayertypes "github.com/agglayer/aggkit/agglayer/types" + bridgetypes "github.com/agglayer/aggkit/bridgesync/types" + "github.com/agglayer/aggkit/log" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" +) + +var bridgeEventTopic = crypto.Keccak256Hash( + []byte("BridgeEvent(uint8,uint32,address,uint32,address,uint256,bytes,uint32)"), +) + +// isClaimedSelector is the 4-byte ABI selector for isClaimed(uint32,uint32). +// keccak256("isClaimed(uint32,uint32)")[:4] +const isClaimedSelector = "0xcc461632" + +// sourceBridgeNetworkMainnet is the sourceBridgeNetwork value for L1 (mainnet) deposits. +// isClaimed(leafIndex, sourceBridgeNetwork) uses 0 for mainnet. +const sourceBridgeNetworkMainnet = 0 + +// RunStepE finds unclaimed L1→L2 bridge deposits and reports them. +// +// Approach: +// 1. Scan L1 bridge for BridgeEvent where destinationNetwork == L2 networkId +// 2. For each deposit, call isClaimed(depositCount, 0) on the L2 bridge contract +// 3. Message deposits (leaf_type=1) are saved separately and never added to the certificate. +// 4. Asset deposits (leaf_type=0): if none, the certificate is passed through unchanged. +// If ignoreUnclaimed=true, detected deposits are logged but the certificate is unchanged. +// If ignoreUnclaimed=false and any assets are found, the step errors (Merkle proofs not yet implemented). +func RunStepE( + ctx context.Context, cfg *Config, + certificate *agglayertypes.Certificate, +) (*StepEResult, error) { + log.Info("═══════════════════════════════════════════") + log.Info(" STEP E — Unclaimed L1→L2 bridge deposits") + log.Info("═══════════════════════════════════════════") + + l1LatestBlock, err := resolveL1LatestBlock(ctx, cfg) + if err != nil { + return nil, err + } + + l1Deposits, err := fetchL1BridgeEvents(ctx, cfg, l1LatestBlock) + if err != nil { + return nil, err + } + log.Infof("L1→L2 deposits found: %d", len(l1Deposits)) + + claimedSet, err := checkClaimedBatch(ctx, cfg, l1Deposits) + if err != nil { + return nil, fmt.Errorf("check isClaimed: %w", err) + } + log.Infof("Already claimed on L2: %d", len(claimedSet)) + + unclaimed := filterUnclaimedDeposits(l1Deposits, claimedSet) + unclaimedAssets, unclaimedMessages := splitByLeafType(unclaimed) + log.Infof("Unclaimed L1→L2 deposits: %d (asset=%d, messages=%d)", + len(unclaimed), len(unclaimedAssets), len(unclaimedMessages)) + + if cfg.Options.BridgeServiceURL != "" { + log.Infof("step E: checking pending bridges from bridge service %s", cfg.Options.BridgeServiceURL) + if err := checkBridgeServicePendingBridges(ctx, cfg, unclaimedAssets); err != nil { + return nil, fmt.Errorf("bridge service pending bridges check: %w", err) + } + } else { + log.Info("Bridge service URL not configured — skipping bridge service pending bridges check") + } + + if len(unclaimedMessages) > 0 { + log.Infof("⚠️ Unclaimed message deposits (leaf_type=1, excluded from certificate): %d", len(unclaimedMessages)) + } else { + log.Info("✅ No unclaimed message deposits found") + } + logUnclaimedAssetSummary(ctx, cfg, unclaimedAssets) + + if len(unclaimedAssets) == 0 { + log.Info("STEP E complete (no unclaimed asset deposits)") + return &StepEResult{ + UnclaimedBridges: unclaimedAssets, + UnclaimedMessages: unclaimedMessages, + FinalCertificate: certificate, + }, nil + } + if cfg.Options.IgnoreUnclaimed { + log.Info("STEP E complete (certificate unchanged) ignored unclaimed deposits") + return &StepEResult{ + UnclaimedBridges: unclaimedAssets, + UnclaimedMessages: unclaimedMessages, + FinalCertificate: certificate, + }, nil + } + + return &StepEResult{ + UnclaimedBridges: unclaimedAssets, + UnclaimedMessages: unclaimedMessages, + FinalCertificate: nil, + }, fmt.Errorf( + "unclaimed deposits not supported, require to implement merkle proofs "+ + "(disable with options.ignoreUnclaimed=true or claim the deposits on L2): %d unclaimed asset deposit(s)", + len(unclaimedAssets), + ) +} + +func resolveL1LatestBlock(ctx context.Context, cfg *Config) (uint64, error) { + latestResult, err := singleRPC(ctx, cfg.L1RPCURL, "eth_blockNumber", nil, defaultRetries) + if err != nil { + return 0, fmt.Errorf("get L1 latest block: %w", err) + } + var latestHex string + if err := json.Unmarshal(latestResult, &latestHex); err != nil { + return 0, fmt.Errorf("parse L1 latest block: %w", err) + } + block := hexToUint64(latestHex) + log.Infof("L1 latest block: %d, scanning from %d", block, cfg.Options.L1StartBlock) + return block, nil +} + +// checkClaimedBatch calls isClaimed(depositCount, 0) on the L2 bridge for each deposit. +// +// isClaimed inputs: +// - leafIndex = depositCount from the BridgeEvent +// - sourceBridgeNetwork = 0 (mainnet), because the deposit originates from L1 +// +// The contract internally computes: +// +// globalIndex = leafIndex + sourceBridgeNetwork * 2^32 +// +// With sourceBridgeNetwork=0 this simplifies to globalIndex = leafIndex. +func checkClaimedBatch( + ctx context.Context, cfg *Config, deposits []L1Deposit, +) (map[uint32]struct{}, error) { + if len(deposits) == 0 { + return nil, nil + } + + calls := make([]RPCCall, len(deposits)) + for i, dep := range deposits { + calls[i] = RPCCall{ + Method: "eth_call", + Params: []any{ + map[string]string{ + "to": cfg.L2BridgeAddress.Hex(), + "data": encodeIsClaimed(dep.DepositCount, sourceBridgeNetworkMainnet), + }, + "latest", + }, + } + } + + results, err := concurrentBatchRPC( + ctx, cfg.L2RPCURL, calls, cfg.Options.RPCBatchSize, cfg.Options.ConcurrencyLimit, + "L2 RPC/isClaimed", + ) + if err != nil { + return nil, fmt.Errorf("batch isClaimed: %w", err) + } + + return parseClaimedResults(results, deposits), nil +} + +// encodeIsClaimed ABI-encodes isClaimed(uint32 leafIndex, uint32 sourceBridgeNetwork). +func encodeIsClaimed(leafIndex, sourceBridgeNetwork uint32) string { + data := make([]byte, 4+64) //nolint:mnd + copy(data[0:4], common.FromHex(isClaimedSelector)) + new(big.Int).SetUint64(uint64(leafIndex)).FillBytes(data[4:36]) + new(big.Int).SetUint64(uint64(sourceBridgeNetwork)).FillBytes(data[36:68]) + return "0x" + common.Bytes2Hex(data) +} + +func parseClaimedResults(results []json.RawMessage, deposits []L1Deposit) map[uint32]struct{} { + claimed := make(map[uint32]struct{}) + for i, result := range results { + if result == nil { + continue + } + var hex string + if json.Unmarshal(result, &hex) != nil { + continue + } + val := hexToBigInt(hex) + if val.Sign() > 0 { + claimed[deposits[i].DepositCount] = struct{}{} + } + } + return claimed +} + +func filterUnclaimedDeposits( + l1Deposits []L1Deposit, claimedSet map[uint32]struct{}, +) []L1Deposit { + var unclaimed []L1Deposit + for _, dep := range l1Deposits { + if _, ok := claimedSet[dep.DepositCount]; !ok { + unclaimed = append(unclaimed, dep) + } + } + return unclaimed +} + +// splitByLeafType partitions deposits into assets (leaf_type=0) and messages (leaf_type=1). +func splitByLeafType(deposits []L1Deposit) (assets, messages []L1Deposit) { + for _, dep := range deposits { + if bridgetypes.LeafType(dep.LeafType) == bridgetypes.LeafTypeMessage { + messages = append(messages, dep) + } else { + assets = append(assets, dep) + } + } + return +} + +// logUnclaimedAssetSummary logs a single summary line plus one line per token group +// showing the total amount. Token names are fetched from the origin-network RPC. +// Native tokens (zero address) are displayed with amounts converted from wei to ETH. +func logUnclaimedAssetSummary(ctx context.Context, cfg *Config, assets []L1Deposit) { + if len(assets) == 0 { + return + } + + type tokenKey struct { + originNetwork uint32 + originAddress common.Address + } + + totals := make(map[tokenKey]*big.Int) + for _, dep := range assets { + key := tokenKey{dep.OriginNetwork, dep.OriginAddress} + if totals[key] == nil { + totals[key] = new(big.Int) + } + if dep.Amount != nil { + totals[key].Add(totals[key], dep.Amount) + } + } + + // Sort keys for deterministic output: by network then address. + keys := make([]tokenKey, 0, len(totals)) + for k := range totals { + keys = append(keys, k) + } + sort.Slice(keys, func(i, j int) bool { + if keys[i].originNetwork != keys[j].originNetwork { + return keys[i].originNetwork < keys[j].originNetwork + } + return keys[i].originAddress.Hex() < keys[j].originAddress.Hex() + }) + + log.Warnf("⚠️ %d unclaimed asset deposit(s):", len(assets)) + for _, key := range keys { + total := totals[key] + name, decimals := fetchTokenInfo(ctx, cfg, key.originNetwork, key.originAddress) + log.Infof(" %s (network=%d): %s (raw %s)", + name, key.originNetwork, formatTokenAmount(total, decimals), total.String()) + } +} + +// fetchTokenInfo returns the token name and decimals for a given origin token. +// For native tokens (zero address) it returns ("ETH", 18) without any RPC call. +// For ERC-20s it calls name() and decimals() using the appropriate RPC URL. +func fetchTokenInfo( + ctx context.Context, cfg *Config, originNetwork uint32, originAddress common.Address, +) (name string, decimals uint8) { + if originAddress == (common.Address{}) { + if originNetwork == 0 { + return "ETH", ethDecimals + } + return fmt.Sprintf("native(net=%d)", originNetwork), ethDecimals + } + + var rpcURL string + switch originNetwork { + case 0: + rpcURL = cfg.L1RPCURL + case cfg.L2NetworkID: + rpcURL = cfg.L2RPCURL + } + + shortAddr := originAddress.Hex()[:10] + "…" + + if rpcURL == "" { + return shortAddr, 0 + } + + name = fetchTokenName(ctx, rpcURL, originAddress) + if name == "" { + name = shortAddr + } + decimals = fetchTokenDecimals(ctx, rpcURL, originAddress) + return name, decimals +} + +const ( + abiSelectorName = "0x06fdde03" // keccak256("name()")[:4] + abiSelectorDecimals = "0x313ce567" // keccak256("decimals()")[:4] +) + +func fetchTokenName(ctx context.Context, rpcURL string, addr common.Address) string { + result, err := singleRPC(ctx, rpcURL, "eth_call", []any{ + map[string]string{"to": addr.Hex(), "data": abiSelectorName}, + "latest", + }, defaultRetries) + if err != nil { + return "" + } + var hex string + if json.Unmarshal(result, &hex) != nil { + return "" + } + return decodeABIString(common.FromHex(hex)) +} + +func fetchTokenDecimals(ctx context.Context, rpcURL string, addr common.Address) uint8 { + result, err := singleRPC(ctx, rpcURL, "eth_call", []any{ + map[string]string{"to": addr.Hex(), "data": abiSelectorDecimals}, + "latest", + }, defaultRetries) + if err != nil { + return 0 + } + var hex string + if json.Unmarshal(result, &hex) != nil { + return 0 + } + data := common.FromHex(hex) + if len(data) < abiWordBytes { + return 0 + } + d, err := safeUint8(new(big.Int).SetBytes(data[len(data)-abiWordBytes:])) + if err != nil { + return 0 + } + return d +} + +// decodeABIString decodes an ABI-encoded string return value (offset + length + data). +func decodeABIString(data []byte) string { + // Layout: 32-byte offset | 32-byte length | UTF-8 bytes + if len(data) < twoABIWords { + return "" + } + strLen := new(big.Int).SetBytes(data[32:64]).Uint64() + if 64+strLen > uint64(len(data)) { + return "" + } + return string(data[64 : 64+strLen]) +} + +// formatTokenAmount formats an amount using the token's decimals. +// The fractional part is shown with full precision (trailing zeros stripped). +// If decimals == 0 the raw integer is shown. +func formatTokenAmount(amount *big.Int, decimals uint8) string { + if amount == nil { + return "0" + } + if decimals == 0 { + return amount.String() + " (raw)" + } + divisor := new(big.Int).Exp(big.NewInt(decimalBase), big.NewInt(int64(decimals)), nil) + whole := new(big.Int).Quo(amount, divisor) + remainder := new(big.Int).Mod(amount, divisor) + + if remainder.Sign() == 0 { + return whole.String() + } + // Pad remainder with leading zeros to fill all decimal places, then strip trailing zeros. + frac := remainder.String() + if len(frac) < int(decimals) { + frac = strings.Repeat("0", int(decimals)-len(frac)) + frac + } + frac = strings.TrimRight(frac, "0") + return whole.String() + "." + frac +} + +func mergeCertificate( + certificate *agglayertypes.Certificate, + newExits []*agglayertypes.BridgeExit, + newImportedExits []*agglayertypes.ImportedBridgeExit, +) *agglayertypes.Certificate { + allExits := make([]*agglayertypes.BridgeExit, 0, + len(certificate.BridgeExits)+len(newExits)) + allExits = append(allExits, certificate.BridgeExits...) + allExits = append(allExits, newExits...) + + allImported := make([]*agglayertypes.ImportedBridgeExit, 0, + len(certificate.ImportedBridgeExits)+len(newImportedExits)) + allImported = append(allImported, certificate.ImportedBridgeExits...) + allImported = append(allImported, newImportedExits...) + + return &agglayertypes.Certificate{ + NetworkID: certificate.NetworkID, + Height: certificate.Height, + PrevLocalExitRoot: certificate.PrevLocalExitRoot, + NewLocalExitRoot: certificate.NewLocalExitRoot, + BridgeExits: allExits, + ImportedBridgeExits: allImported, + } +} + +const ( + bridgeSvcPageSize = 1000 + // BridgeServiceTypeAggkit selects the aggkit bridge service API (/bridge/v1/bridges). + BridgeServiceTypeAggkit = "aggkit" + // BridgeServiceTypeZkevm selects the zkevm-bridge-service API (/pending-bridges). + BridgeServiceTypeZkevm = "zkevm" + // leafTypeAsset is the leaf_type value for asset (ERC-20 / native) bridge deposits. + leafTypeAsset uint32 = 0 +) + +// checkBridgeServicePendingBridges fetches the pending-bridges set from the configured bridge +// service (aggkit or zkevm) and compares it against the unclaimed deposits found on L1. +func checkBridgeServicePendingBridges(ctx context.Context, cfg *Config, unclaimed []L1Deposit) error { + baseURL := strings.TrimRight(cfg.Options.BridgeServiceURL, "/") + + var label string + var svcCounts map[uint32]struct{} + + switch cfg.Options.BridgeServiceType { + case BridgeServiceTypeZkevm: + label = "zkevm bridge service" + log.Infof("Querying zkevm bridge service for pending bridges (url=%s, l2NetworkID=%d)", baseURL, cfg.L2NetworkID) + var fetchErr error + svcCounts, fetchErr = fetchZkevmPendingBridges(ctx, baseURL, leafTypeAsset) + if fetchErr != nil { + return fetchErr + } + default: + label = "aggkit bridge service" + log.Infof("Querying aggkit bridge service for pending bridges (url=%s, l2NetworkID=%d)", baseURL, cfg.L2NetworkID) + var fetchErr error + svcCounts, fetchErr = fetchAggkitPendingBridges(ctx, cfg, baseURL, leafTypeAsset) + if fetchErr != nil { + return fetchErr + } + } + + return reportPendingDiscrepancies(label, unclaimed, svcCounts) +} + +// reportPendingDiscrepancies compares the set of deposit counts reported by the bridge service +// against the set from the L1 scan and returns an error describing any differences. +func reportPendingDiscrepancies(label string, unclaimed []L1Deposit, svcCounts map[uint32]struct{}) error { + scanSet := make(map[uint32]struct{}, len(unclaimed)) + for _, dep := range unclaimed { + scanSet[dep.DepositCount] = struct{}{} + } + + var inSvcOnly, inScanOnly []uint32 + for dc := range svcCounts { + if _, ok := scanSet[dc]; !ok { + inSvcOnly = append(inSvcOnly, dc) + } + } + for dc := range scanSet { + if _, ok := svcCounts[dc]; !ok { + inScanOnly = append(inScanOnly, dc) + } + } + + if len(inSvcOnly) == 0 && len(inScanOnly) == 0 { + log.Infof("%s pending bridges match L1 scan (%d unclaimed deposit(s))", label, len(unclaimed)) + return nil + } + + sort.Slice(inSvcOnly, func(i, j int) bool { return inSvcOnly[i] < inSvcOnly[j] }) + sort.Slice(inScanOnly, func(i, j int) bool { return inScanOnly[i] < inScanOnly[j] }) + + var parts []string + if len(inSvcOnly) > 0 { + parts = append(parts, + fmt.Sprintf("%s reports %d deposit(s) not found by L1 scan: depositCounts=%v", + label, len(inSvcOnly), inSvcOnly)) + } + if len(inScanOnly) > 0 { + parts = append(parts, + fmt.Sprintf("L1 scan found %d deposit(s) not reported by %s: depositCounts=%v", + len(inScanOnly), label, inScanOnly)) + } + return fmt.Errorf("bridge service pending bridges mismatch: %s", strings.Join(parts, "; ")) +} + +// ── aggkit bridge service ──────────────────────────────────────────────────── + +// aggkitBridgeEntry is a minimal bridge event from the aggkit bridge service REST API. +type aggkitBridgeEntry struct { + LeafType uint8 `json:"leaf_type"` + OriginNetwork uint32 `json:"origin_network"` + OriginAddress string `json:"origin_address"` + DestinationNetwork uint32 `json:"destination_network"` + DestinationAddress string `json:"destination_address"` + Amount string `json:"amount"` + Metadata string `json:"metadata"` + DepositCount uint32 `json:"deposit_count"` + TxHash string `json:"tx_hash"` + BlockNum uint64 `json:"block_num"` +} + +type aggkitBridgesResult struct { + Bridges []*aggkitBridgeEntry `json:"bridges"` + Count int `json:"count"` +} + +// fetchAggkitPendingBridges fetches unclaimed deposits from the aggkit bridge service +// (GET /bridge/v1/bridges?network_id=0&leaf_type= + isClaimed check) and returns the set of deposit counts. +func fetchAggkitPendingBridges( + ctx context.Context, cfg *Config, baseURL string, leafType uint32, +) (map[uint32]struct{}, error) { + var matching []*aggkitBridgeEntry + for page := 1; ; page++ { + reqURL := fmt.Sprintf("%s/bridge/v1/bridges?network_id=0&leaf_type=%d&page_number=%d&page_size=%d", + baseURL, leafType, page, bridgeSvcPageSize) + + body, err := httpGetJSON(ctx, reqURL) + if err != nil { + return nil, fmt.Errorf("aggkit bridge service page %d: %w", page, err) + } + + var result aggkitBridgesResult + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("parse aggkit bridge service response page %d: %w", page, err) + } + + for _, b := range result.Bridges { + if b.DestinationNetwork == cfg.L2NetworkID { + matching = append(matching, b) + } + } + log.Infof("Aggkit bridge service page %d: %d entries, %d targeting L2", page, len(result.Bridges), len(matching)) + + if len(result.Bridges) < bridgeSvcPageSize { + break + } + } + + deposits := make([]L1Deposit, len(matching)) + for i, b := range matching { + deposits[i] = L1Deposit{DepositCount: b.DepositCount} + } + claimedSet, err := checkClaimedBatch(ctx, cfg, deposits) + if err != nil { + return nil, fmt.Errorf("isClaimed check for aggkit bridge service entries: %w", err) + } + + svcCounts := make(map[uint32]struct{}) + for _, b := range matching { + if _, ok := claimedSet[b.DepositCount]; !ok { + svcCounts[b.DepositCount] = struct{}{} + } + } + + return svcCounts, nil +} + +// ── zkevm bridge service ───────────────────────────────────────────────────── + +// zkevmDeposit matches the JSON-encoded Deposit message returned by the zkevm-bridge-service +// gRPC gateway (field names are lowerCamelCase per protobuf JSON encoding). +type zkevmDeposit struct { + LeafType uint32 `json:"leaf_type"` + OrigNet uint32 `json:"orig_net"` + OrigAddr string `json:"orig_addr"` + Amount string `json:"amount"` + DestNet uint32 `json:"dest_net"` + DestAddr string `json:"dest_addr"` + BlockNum string `json:"block_num"` + DepositCnt uint32 `json:"deposit_cnt"` + NetworkID uint32 `json:"network_id"` + TxHash string `json:"tx_hash"` + ClaimTxHash string `json:"claim_tx_hash"` + Metadata string `json:"metadata"` + ReadyForClaim bool `json:"ready_for_claim"` + GlobalIndex string `json:"global_index"` +} + +type zkevmPendingBridgesResponse struct { + Deposits []*zkevmDeposit `json:"deposits"` + TotalCnt string `json:"total_cnt"` +} + +// checkZkevmPendingBridges fetches pending (unclaimed, ready-to-claim) deposits from the +// zkevm-bridge-service (GET /pending-bridges, both leaf types) and compares against the L1 scan. +// fetchZkevmPendingBridges pages through GET /pending-bridges for the given leafType and +// returns the set of deposit counts reported as pending by the zkevm bridge service. +func fetchZkevmPendingBridges(ctx context.Context, baseURL string, leafType uint32) (map[uint32]struct{}, error) { + svcCounts := make(map[uint32]struct{}) + + var offset uint32 + for { + reqURL := fmt.Sprintf("%s/pending-bridges?dest_net=1&leaf_type=%d&limit=%d&offset=%d", + baseURL, leafType, bridgeSvcPageSize, offset) + + body, err := httpGetJSON(ctx, reqURL) + if err != nil { + return nil, fmt.Errorf("zkevm bridge service (leaf_type=%d, offset=%d): %w", leafType, offset, err) + } + var result zkevmPendingBridgesResponse + if err := json.Unmarshal(body, &result); err != nil { + log.Infof("Response body: %s", string(body)) + return nil, fmt.Errorf("parse zkevm bridge service response (leaf_type=%d): %w", leafType, err) + } + totalCnt, err := strconv.ParseUint(result.TotalCnt, 10, 64) + if err != nil { + return nil, fmt.Errorf("parse total_cnt %q (leaf_type=%d): %w", result.TotalCnt, leafType, err) + } + + for _, d := range result.Deposits { + svcCounts[d.DepositCnt] = struct{}{} + } + log.Infof("Zkevm bridge service leaf_type=%d offset=%d: %d/%d deposits", + leafType, offset, len(result.Deposits), totalCnt) + + offset += uint32(len(result.Deposits)) + if len(result.Deposits) == 0 || uint64(offset) >= totalCnt { + break + } + } + + return svcCounts, nil +} + +// fetchL1BridgeEvents scans L1 for BridgeEvents using a worker pool. +func fetchL1BridgeEvents( + ctx context.Context, cfg *Config, l1LatestBlock uint64, +) ([]L1Deposit, error) { + fromBlock := cfg.Options.L1StartBlock + blockRange := cfg.Options.BlockRange + concurrency := cfg.Options.ConcurrencyLimit + + if l1LatestBlock < fromBlock { + return nil, nil + } + + type blockRangeJob struct{ from, to uint64 } + var jobs []blockRangeJob + for start := fromBlock; start <= l1LatestBlock; start += uint64(blockRange) { + end := min(start+uint64(blockRange)-1, l1LatestBlock) + jobs = append(jobs, blockRangeJob{from: start, to: end}) + } + + log.Infof("Fetching L1 BridgeEvents: blocks %d→%d, %d ranges, concurrency=%d", + fromBlock, l1LatestBlock, len(jobs), concurrency) + + var allDeposits []L1Deposit + + err := runWorkerPool( + ctx, jobs, concurrency, + func(j blockRangeJob) ([]L1Deposit, error) { + return fetchBridgeEventsInRange( + ctx, cfg.L1RPCURL, cfg.L1BridgeAddress, cfg.L2NetworkID, j.from, j.to, + ) + }, + func(deposits []L1Deposit) { + allDeposits = append(allDeposits, deposits...) + }, + "L1 BridgeEvent", + ) + if err != nil { + return nil, fmt.Errorf("L1 BridgeEvent scan: %w", err) + } + + log.Infof("L1 BridgeEvent: %d events found", len(allDeposits)) + return allDeposits, nil +} + +// fetchBridgeEventsInRange fetches BridgeEvent logs in a single block range. +func fetchBridgeEventsInRange( + ctx context.Context, rpcURL string, bridgeAddress common.Address, + l2NetworkID uint32, fromBlock, toBlock uint64, +) ([]L1Deposit, error) { + result, err := singleRPC(ctx, rpcURL, "eth_getLogs", []any{ + map[string]any{ + "address": bridgeAddress.Hex(), + "topics": []string{bridgeEventTopic.Hex()}, + "fromBlock": toBlockTag(fromBlock), + "toBlock": toBlockTag(toBlock), + }, + }, defaultRetries) + if err != nil { + return nil, err + } + + var logs []struct { + Data string `json:"data"` + BlockNumber string `json:"blockNumber"` + TransactionHash string `json:"transactionHash"` + } + if err := json.Unmarshal(result, &logs); err != nil { + return nil, fmt.Errorf("unmarshal logs: %w", err) + } + + var deposits []L1Deposit + for _, lg := range logs { + dep, err := decodeBridgeEvent(lg.Data, lg.BlockNumber, lg.TransactionHash) + if err != nil { + continue + } + if dep.DestinationNetwork == l2NetworkID { + deposits = append(deposits, dep) + } + } + return deposits, nil +} + +// decodeBridgeEvent decodes ABI-encoded BridgeEvent data. +// Layout: leafType | originNetwork | originAddress | destNetwork | +// +// destAddress | amount | metadataOffset | depositCount | metadata... +func decodeBridgeEvent( + dataHex, blockNumberHex, txHashHex string, +) (L1Deposit, error) { + data := common.FromHex(dataHex) + const minDataLen = 256 + if len(data) < minDataLen { + return L1Deposit{}, fmt.Errorf("data too short: %d bytes", len(data)) + } + + metadataOffset := new(big.Int).SetBytes(data[192:224]).Uint64() + metadata, err := extractMetadata(data, metadataOffset) + if err != nil { + return L1Deposit{}, err + } + + return parseBridgeFields(data, metadata, blockNumberHex, txHashHex) +} + +func parseBridgeFields( + data, metadata []byte, blockNumberHex, txHashHex string, +) (L1Deposit, error) { + leafType, err := safeUint8(new(big.Int).SetBytes(data[0:32])) + if err != nil { + return L1Deposit{}, fmt.Errorf("leafType: %w", err) + } + originNetwork, err := safeUint32(new(big.Int).SetBytes(data[32:64])) + if err != nil { + return L1Deposit{}, fmt.Errorf("originNetwork: %w", err) + } + destNetwork, err := safeUint32(new(big.Int).SetBytes(data[96:128])) + if err != nil { + return L1Deposit{}, fmt.Errorf("destNetwork: %w", err) + } + depositCount, err := safeUint32(new(big.Int).SetBytes(data[224:256])) + if err != nil { + return L1Deposit{}, fmt.Errorf("depositCount: %w", err) + } + + return L1Deposit{ + LeafType: leafType, + OriginNetwork: originNetwork, + OriginAddress: common.BytesToAddress(data[64:96]), + DestinationNetwork: destNetwork, + DestinationAddress: common.BytesToAddress(data[128:160]), + Amount: new(big.Int).SetBytes(data[160:192]), + Metadata: metadata, + DepositCount: depositCount, + BlockNumber: hexToUint64(blockNumberHex), + TxHash: common.HexToHash(txHashHex), + }, nil +} + +func extractMetadata(data []byte, metadataOffset uint64) ([]byte, error) { + const abiWordSize = 32 + if metadataOffset+abiWordSize > uint64(len(data)) { + return nil, nil + } + metadataLen := new(big.Int).SetBytes( + data[metadataOffset : metadataOffset+abiWordSize], + ).Uint64() + if metadataLen > maxMetadataSize { + return nil, fmt.Errorf( + "metadata too large: %d bytes (max %d)", metadataLen, maxMetadataSize, + ) + } + metadataStart := metadataOffset + abiWordSize + if metadataStart+metadataLen > uint64(len(data)) { + return nil, nil + } + metadata := make([]byte, metadataLen) + copy(metadata, data[metadataStart:metadataStart+metadataLen]) + return metadata, nil +} diff --git a/tools/exit_certificate/step_e_rpc_test.go b/tools/exit_certificate/step_e_rpc_test.go new file mode 100644 index 000000000..c2434eaac --- /dev/null +++ b/tools/exit_certificate/step_e_rpc_test.go @@ -0,0 +1,303 @@ +package exit_certificate + +import ( + "context" + "encoding/json" + "math/big" + "net/http" + "net/http/httptest" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +// makeBridgeEventData builds the 256-byte ABI payload a BridgeEvent log carries (empty metadata). +func makeBridgeEventData(leafType uint8, originNet, destNet, depositCount uint32, amount int64) string { + data := make([]byte, 256) + data[31] = leafType + new(big.Int).SetUint64(uint64(originNet)).FillBytes(data[32:64]) + new(big.Int).SetUint64(uint64(destNet)).FillBytes(data[96:128]) + new(big.Int).SetInt64(amount).FillBytes(data[160:192]) + new(big.Int).SetUint64(256).FillBytes(data[192:224]) // metadataOffset past the words → empty + new(big.Int).SetUint64(uint64(depositCount)).FillBytes(data[224:256]) + return "0x" + common.Bytes2Hex(data) +} + +func TestSplitByLeafType(t *testing.T) { + t.Parallel() + deposits := []L1Deposit{ + {DepositCount: 0, LeafType: 0}, // asset + {DepositCount: 1, LeafType: 1}, // message + {DepositCount: 2, LeafType: 0}, // asset + } + assets, messages := splitByLeafType(deposits) + require.Len(t, assets, 2) + require.Len(t, messages, 1) + require.Equal(t, uint32(1), messages[0].DepositCount) +} + +func TestResolveL1LatestBlock(t *testing.T) { + t.Parallel() + url := newBatchRPCServer(t, func(method string, _ []json.RawMessage) any { + require.Equal(t, rpcMethodEthBlockNumber, method) + return "0x1a4" // 420 + }) + cfg := &Config{L1RPCURL: url} + block, err := resolveL1LatestBlock(context.Background(), cfg) + require.NoError(t, err) + require.Equal(t, uint64(420), block) +} + +func TestCheckClaimedBatch(t *testing.T) { + t.Parallel() + deposits := []L1Deposit{{DepositCount: 0}, {DepositCount: 1}, {DepositCount: 2}} + // claim only deposit 1: decode the leafIndex from the isClaimed call data (bytes [4:36]). + url := newBatchRPCServer(t, func(method string, params []json.RawMessage) any { + require.Equal(t, rpcMethodEthCall, method) + var call struct { + Data string `json:"data"` + } + require.NoError(t, json.Unmarshal(params[0], &call)) + raw := common.FromHex(call.Data) + leafIndex := new(big.Int).SetBytes(raw[4:36]).Uint64() + if leafIndex == 1 { + return "0x0000000000000000000000000000000000000000000000000000000000000001" // claimed + } + return "0x0000000000000000000000000000000000000000000000000000000000000000" // not claimed + }) + cfg := &Config{L2RPCURL: url, L2BridgeAddress: common.HexToAddress("0xbridge"), + Options: Options{RPCBatchSize: 10, ConcurrencyLimit: 2}} + + claimed, err := checkClaimedBatch(context.Background(), cfg, deposits) + require.NoError(t, err) + require.Len(t, claimed, 1) + _, ok := claimed[1] + require.True(t, ok) +} + +func TestCheckClaimedBatchEmpty(t *testing.T) { + t.Parallel() + claimed, err := checkClaimedBatch(context.Background(), &Config{}, nil) + require.NoError(t, err) + require.Empty(t, claimed) +} + +func TestFetchBridgeEventsInRange(t *testing.T) { + t.Parallel() + url := newBatchRPCServer(t, func(method string, _ []json.RawMessage) any { + require.Equal(t, "eth_getLogs", method) + return []map[string]string{ + { // targets L2 (destNet=1) → kept + "data": makeBridgeEventData(0, 0, 1, 5, 1000), + "blockNumber": "0x10", + "transactionHash": common.HexToHash("0xaaa").Hex(), + }, + { // targets a different network (destNet=9) → filtered out + "data": makeBridgeEventData(0, 0, 9, 6, 2000), + "blockNumber": "0x11", + "transactionHash": common.HexToHash("0xbbb").Hex(), + }, + } + }) + deposits, err := fetchBridgeEventsInRange(context.Background(), url, common.HexToAddress("0xbridge"), 1, 0, 100) + require.NoError(t, err) + require.Len(t, deposits, 1) + require.Equal(t, uint32(5), deposits[0].DepositCount) + require.Equal(t, big.NewInt(1000), deposits[0].Amount) +} + +func TestFetchL1BridgeEvents(t *testing.T) { + t.Parallel() + url := newBatchRPCServer(t, func(method string, _ []json.RawMessage) any { + require.Equal(t, "eth_getLogs", method) + return []map[string]string{{ + "data": makeBridgeEventData(0, 0, 1, 0, 50), + "blockNumber": "0x1", + "transactionHash": common.HexToHash("0xaaa").Hex(), + }} + }) + cfg := &Config{L1RPCURL: url, L2NetworkID: 1, + Options: Options{BlockRange: 50, ConcurrencyLimit: 2, L1StartBlock: 0}} + deposits, err := fetchL1BridgeEvents(context.Background(), cfg, 100) + require.NoError(t, err) + require.NotEmpty(t, deposits) +} + +func TestFetchL1BridgeEventsEmptyRange(t *testing.T) { + t.Parallel() + cfg := &Config{Options: Options{L1StartBlock: 100}} + deposits, err := fetchL1BridgeEvents(context.Background(), cfg, 10) // latest < start + require.NoError(t, err) + require.Nil(t, deposits) +} + +func TestFetchTokenInfoNative(t *testing.T) { + t.Parallel() + name, decimals := fetchTokenInfo(context.Background(), &Config{}, 0, common.Address{}) + require.Equal(t, "ETH", name) + require.Equal(t, uint8(18), decimals) + + // non-zero origin network, zero address → native(net=N) + name, _ = fetchTokenInfo(context.Background(), &Config{}, 5, common.Address{}) + require.Contains(t, name, "native(net=5)") +} + +// abiEncodeString builds the ABI return data for a string (offset|length|utf8 padded to 32). +func abiEncodeString(s string) string { + paddedLen := ((len(s) + 31) / 32) * 32 + out := make([]byte, 64+paddedLen) + new(big.Int).SetUint64(32).FillBytes(out[0:32]) // offset + new(big.Int).SetUint64(uint64(len(s))).FillBytes(out[32:64]) // length + copy(out[64:], s) + return "0x" + common.Bytes2Hex(out) +} + +func TestFetchTokenNameAndDecimals(t *testing.T) { + t.Parallel() + url := newBatchRPCServer(t, func(method string, params []json.RawMessage) any { + require.Equal(t, rpcMethodEthCall, method) + var call struct { + Data string `json:"data"` + } + require.NoError(t, json.Unmarshal(params[0], &call)) + switch call.Data { + case abiSelectorName: + return abiEncodeString("MyToken") + case abiSelectorDecimals: + word := make([]byte, 32) + word[31] = 6 + return "0x" + common.Bytes2Hex(word) + } + return "0x" + }) + + require.Equal(t, "MyToken", fetchTokenName(context.Background(), url, common.BytesToAddress([]byte("tok")))) + require.Equal(t, uint8(6), fetchTokenDecimals(context.Background(), url, common.BytesToAddress([]byte("tok")))) + + // fetchTokenInfo ERC-20 branch (origin network 0 → uses L1 RPC) + cfg := &Config{L1RPCURL: url, L2NetworkID: 1} + name, decimals := fetchTokenInfo(context.Background(), cfg, 0, common.BytesToAddress([]byte("tok"))) + require.Equal(t, "MyToken", name) + require.Equal(t, uint8(6), decimals) +} + +func TestFetchTokenInfoNoRPC(t *testing.T) { + t.Parallel() + // non-native token but no RPC URL for its origin network → short address, 0 decimals. + name, decimals := fetchTokenInfo(context.Background(), &Config{}, 0, common.HexToAddress("0xabcdef1234")) + require.Contains(t, name, "0x") + require.Equal(t, uint8(0), decimals) +} + +func TestLogUnclaimedAssetSummaryNative(t *testing.T) { + t.Parallel() + // native assets need no RPC; just exercise the grouping/sorting/logging path. + assets := []L1Deposit{ + {OriginNetwork: 0, OriginAddress: common.Address{}, Amount: big.NewInt(1e18)}, + {OriginNetwork: 0, OriginAddress: common.Address{}, Amount: big.NewInt(2e18)}, + } + require.NotPanics(t, func() { + logUnclaimedAssetSummary(context.Background(), &Config{}, assets) + logUnclaimedAssetSummary(context.Background(), &Config{}, nil) // empty → early return + }) +} + +// --- bridge service HTTP cross-check ------------------------------------------------------------- + +func TestFetchZkevmPendingBridges(t *testing.T) { + t.Parallel() + // two pages: total_cnt=3, page size capped so the loop iterates. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + offset := r.URL.Query().Get("offset") + var deposits []map[string]any + if offset == "0" { + deposits = []map[string]any{ + {"deposit_cnt": 10}, {"deposit_cnt": 11}, + } + } else { + deposits = []map[string]any{{"deposit_cnt": 12}} + } + _ = json.NewEncoder(w).Encode(map[string]any{"deposits": deposits, "total_cnt": "3"}) + })) + t.Cleanup(srv.Close) + + got, err := fetchZkevmPendingBridges(context.Background(), srv.URL, leafTypeAsset) + require.NoError(t, err) + require.Len(t, got, 3) + for _, dc := range []uint32{10, 11, 12} { + _, ok := got[dc] + require.True(t, ok, "deposit %d", dc) + } +} + +func TestCheckBridgeServicePendingBridgesZkevmMatch(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{ + "deposits": []map[string]any{{"deposit_cnt": 7}}, "total_cnt": "1", + }) + })) + t.Cleanup(srv.Close) + + cfg := &Config{L2NetworkID: 1, Options: Options{ + BridgeServiceURL: srv.URL, BridgeServiceType: BridgeServiceTypeZkevm, + }} + // L1 scan also found deposit 7 → match, no error + err := checkBridgeServicePendingBridges(context.Background(), cfg, []L1Deposit{{DepositCount: 7}}) + require.NoError(t, err) + + // L1 scan found a different set → mismatch error + err = checkBridgeServicePendingBridges(context.Background(), cfg, []L1Deposit{{DepositCount: 8}}) + require.Error(t, err) + require.Contains(t, err.Error(), "mismatch") +} + +func TestFetchAggkitPendingBridges(t *testing.T) { + t.Parallel() + // aggkit bridge service: one page of two bridges targeting L2 (dest network 1). + svc := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/bridge/v1/bridges", r.URL.Path) + _ = json.NewEncoder(w).Encode(map[string]any{ + "bridges": []map[string]any{ + {"deposit_count": 20, "destination_network": 1}, + {"deposit_count": 21, "destination_network": 1}, + {"deposit_count": 99, "destination_network": 2}, // other network → ignored + }, + "count": 3, + }) + })) + t.Cleanup(svc.Close) + + // isClaimed: claim deposit 21 so only 20 remains pending. + rpc := newBatchRPCServer(t, func(method string, params []json.RawMessage) any { + var call struct { + Data string `json:"data"` + } + _ = json.Unmarshal(params[0], &call) + raw := common.FromHex(call.Data) + if new(big.Int).SetBytes(raw[4:36]).Uint64() == 21 { + return "0x0000000000000000000000000000000000000000000000000000000000000001" + } + return "0x0000000000000000000000000000000000000000000000000000000000000000" + }) + + cfg := &Config{L2RPCURL: rpc, L2NetworkID: 1, L2BridgeAddress: common.HexToAddress("0xbridge"), + Options: Options{RPCBatchSize: 10, ConcurrencyLimit: 2}} + + got, err := fetchAggkitPendingBridges(context.Background(), cfg, svc.URL, leafTypeAsset) + require.NoError(t, err) + require.Len(t, got, 1) + _, ok := got[20] + require.True(t, ok) +} + +func TestFetchZkevmPendingBridgesHTTPError(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + t.Cleanup(srv.Close) + _, err := fetchZkevmPendingBridges(context.Background(), srv.URL, leafTypeAsset) + require.Error(t, err) +} diff --git a/tools/exit_certificate/step_e_runstep_test.go b/tools/exit_certificate/step_e_runstep_test.go new file mode 100644 index 000000000..b87ec53a4 --- /dev/null +++ b/tools/exit_certificate/step_e_runstep_test.go @@ -0,0 +1,270 @@ +package exit_certificate + +import ( + "context" + "encoding/json" + "io" + "math/big" + "net/http" + "net/http/httptest" + "strings" + "testing" + + agglayertypes "github.com/agglayer/aggkit/agglayer/types" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +// newBatchRPCStub starts an httptest server that handles both single and batched JSON-RPC requests, +// dispatching every call to respond. concurrentBatchRPC (used by isClaimed) sends batches, which the +// single-request newRPCStub cannot decode. +func newBatchRPCStub(t *testing.T, respond rpcResponder) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + raw, err := io.ReadAll(r.Body) + require.NoError(t, err) + w.Header().Set("Content-Type", "application/json") + + trimmed := strings.TrimLeft(string(raw), " \t\r\n") + if strings.HasPrefix(trimmed, "[") { + var reqs []jsonRPCRequest + require.NoError(t, json.Unmarshal(raw, &reqs)) + resps := make([]jsonRPCResponse, len(reqs)) + for i, req := range reqs { + params, _ := req.Params.([]any) + result, rpcErr := respond(req.Method, params) + resps[i] = jsonRPCResponse{JSONRPC: "2.0", ID: req.ID, Result: result, Error: rpcErr} + } + _ = json.NewEncoder(w).Encode(resps) + return + } + + var req jsonRPCRequest + require.NoError(t, json.Unmarshal(raw, &req)) + params, _ := req.Params.([]any) + result, rpcErr := respond(req.Method, params) + _ = json.NewEncoder(w).Encode(jsonRPCResponse{JSONRPC: "2.0", ID: req.ID, Result: result, Error: rpcErr}) + })) + t.Cleanup(srv.Close) + return srv +} + +// bridgeEventData builds the 256-byte ABI payload of a BridgeEvent log: the metadata offset is set +// past the end of the buffer so extractMetadata yields no metadata, keeping the fixture minimal. +func bridgeEventData(leafType uint8, originNetwork, destNetwork, depositCount uint32, amount *big.Int) []byte { + data := make([]byte, 256) + data[31] = leafType + big.NewInt(int64(originNetwork)).FillBytes(data[32:64]) + // originAddress data[64:96] left zero (native token) + big.NewInt(int64(destNetwork)).FillBytes(data[96:128]) + // destAddress data[128:160] left zero + if amount != nil { + amount.FillBytes(data[160:192]) + } + big.NewInt(256).FillBytes(data[192:224]) // metadataOffset past end → no metadata + big.NewInt(int64(depositCount)).FillBytes(data[224:256]) + return data +} + +// bridgeLogsResult marshals a single BridgeEvent log entry as eth_getLogs returns it. +func bridgeLogsResult(t *testing.T, data []byte) json.RawMessage { + t.Helper() + out, err := json.Marshal([]map[string]string{{ + "data": "0x" + common.Bytes2Hex(data), + "blockNumber": "0x1", + "transactionHash": common.HexToHash("0xabc").Hex(), + }}) + require.NoError(t, err) + return out +} + +// claimedResult encodes the isClaimed eth_call return value (non-zero = claimed). +func claimedResult(claimed bool) json.RawMessage { + if claimed { + return quoted("0x0000000000000000000000000000000000000000000000000000000000000001") + } + return quoted("0x0000000000000000000000000000000000000000000000000000000000000000") +} + +// stepEConfig builds a Config wired to the given stub URL for both L1 and L2 RPC. +func stepEConfig(url string) *Config { + return &Config{ + L1RPCURL: url, + L2RPCURL: url, + L1BridgeAddress: common.HexToAddress("0xbridge"), + L2BridgeAddress: common.HexToAddress("0xbridge"), + L2NetworkID: 1, + Options: Options{ + L1StartBlock: 0, + BlockRange: 5000, + RPCBatchSize: 200, + ConcurrencyLimit: 4, + }, + } +} + +func emptyCert() *agglayertypes.Certificate { + return &agglayertypes.Certificate{NetworkID: 1} +} + +func TestRunStepE_NoDeposits(t *testing.T) { + t.Parallel() + srv := newBatchRPCStub(t, func(method string, _ []any) (json.RawMessage, *jsonRPCError) { + switch method { + case rpcMethodEthBlockNumber: + return quoted("0x10"), nil + case rpcMethodEthGetLogs: + return json.RawMessage(`[]`), nil + default: + t.Fatalf("unexpected method %s", method) + return nil, nil + } + }) + + res, err := RunStepE(context.Background(), stepEConfig(srv.URL), emptyCert()) + require.NoError(t, err) + require.Empty(t, res.UnclaimedBridges) + require.NotNil(t, res.FinalCertificate) +} + +func TestRunStepE_AllClaimed(t *testing.T) { + t.Parallel() + data := bridgeEventData(0, 0, 1, 7, big.NewInt(100)) + srv := newBatchRPCStub(t, func(method string, _ []any) (json.RawMessage, *jsonRPCError) { + switch method { + case rpcMethodEthBlockNumber: + return quoted("0x10"), nil + case rpcMethodEthGetLogs: + return bridgeLogsResult(t, data), nil + case rpcMethodEthCall: + return claimedResult(true), nil + default: + t.Fatalf("unexpected method %s", method) + return nil, nil + } + }) + + res, err := RunStepE(context.Background(), stepEConfig(srv.URL), emptyCert()) + require.NoError(t, err) + require.Empty(t, res.UnclaimedBridges) +} + +func TestRunStepE_UnclaimedAssetErrors(t *testing.T) { + t.Parallel() + data := bridgeEventData(0, 0, 1, 7, big.NewInt(100)) + srv := newBatchRPCStub(t, func(method string, _ []any) (json.RawMessage, *jsonRPCError) { + switch method { + case rpcMethodEthBlockNumber: + return quoted("0x10"), nil + case rpcMethodEthGetLogs: + return bridgeLogsResult(t, data), nil + case rpcMethodEthCall: + return claimedResult(false), nil + default: + t.Fatalf("unexpected method %s", method) + return nil, nil + } + }) + + res, err := RunStepE(context.Background(), stepEConfig(srv.URL), emptyCert()) + require.Error(t, err) + require.Contains(t, err.Error(), "unclaimed deposits not supported") + require.Len(t, res.UnclaimedBridges, 1) +} + +func TestRunStepE_UnclaimedAssetIgnored(t *testing.T) { + t.Parallel() + data := bridgeEventData(0, 0, 1, 7, big.NewInt(100)) + cfg := stepEConfig("") + srv := newBatchRPCStub(t, func(method string, _ []any) (json.RawMessage, *jsonRPCError) { + switch method { + case rpcMethodEthBlockNumber: + return quoted("0x10"), nil + case rpcMethodEthGetLogs: + return bridgeLogsResult(t, data), nil + case rpcMethodEthCall: + return claimedResult(false), nil + default: + return quoted("0x"), nil + } + }) + cfg.L1RPCURL = srv.URL + cfg.L2RPCURL = srv.URL + cfg.Options.IgnoreUnclaimed = true + + res, err := RunStepE(context.Background(), cfg, emptyCert()) + require.NoError(t, err) + require.Len(t, res.UnclaimedBridges, 1) + require.NotNil(t, res.FinalCertificate) +} + +func TestRunStepE_UnclaimedMessagesOnly(t *testing.T) { + t.Parallel() + // leaf_type=1 (message) → excluded from certificate, no asset error. + data := bridgeEventData(1, 0, 1, 9, big.NewInt(0)) + srv := newBatchRPCStub(t, func(method string, _ []any) (json.RawMessage, *jsonRPCError) { + switch method { + case rpcMethodEthBlockNumber: + return quoted("0x10"), nil + case rpcMethodEthGetLogs: + return bridgeLogsResult(t, data), nil + case rpcMethodEthCall: + return claimedResult(false), nil + default: + return quoted("0x"), nil + } + }) + + res, err := RunStepE(context.Background(), stepEConfig(srv.URL), emptyCert()) + require.NoError(t, err) + require.Empty(t, res.UnclaimedBridges) + require.Len(t, res.UnclaimedMessages, 1) +} + +func TestRunStepE_BridgeServiceMatch(t *testing.T) { + t.Parallel() + // One unclaimed asset on L1; the aggkit bridge service reports the same deposit count, so the + // cross-check passes and (with IgnoreUnclaimed) the step succeeds. + data := bridgeEventData(0, 0, 1, 7, big.NewInt(100)) + + bridgeSvc := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.True(t, strings.Contains(r.URL.Path, "/bridge/v1/bridges")) + resp := aggkitBridgesResult{ + Bridges: []*aggkitBridgeEntry{{DepositCount: 7, DestinationNetwork: 1, LeafType: 0}}, + Count: 1, + } + _ = json.NewEncoder(w).Encode(resp) + })) + defer bridgeSvc.Close() + + srv := newBatchRPCStub(t, func(method string, _ []any) (json.RawMessage, *jsonRPCError) { + switch method { + case rpcMethodEthBlockNumber: + return quoted("0x10"), nil + case rpcMethodEthGetLogs: + return bridgeLogsResult(t, data), nil + case rpcMethodEthCall: + return claimedResult(false), nil + default: + return quoted("0x"), nil + } + }) + + cfg := stepEConfig(srv.URL) + cfg.Options.IgnoreUnclaimed = true + cfg.Options.BridgeServiceURL = bridgeSvc.URL + cfg.Options.BridgeServiceType = BridgeServiceTypeAggkit + + res, err := RunStepE(context.Background(), cfg, emptyCert()) + require.NoError(t, err) + require.Len(t, res.UnclaimedBridges, 1) +} + +func TestRunStepE_L1BlockError(t *testing.T) { + t.Parallel() + srv := newBatchRPCStub(t, func(string, []any) (json.RawMessage, *jsonRPCError) { + return nil, revertErr() + }) + _, err := RunStepE(context.Background(), stepEConfig(srv.URL), emptyCert()) + require.Error(t, err) +} diff --git a/tools/exit_certificate/step_e_test.go b/tools/exit_certificate/step_e_test.go new file mode 100644 index 000000000..18b2afd5b --- /dev/null +++ b/tools/exit_certificate/step_e_test.go @@ -0,0 +1,232 @@ +package exit_certificate + +import ( + "encoding/json" + "math/big" + "testing" + + agglayertypes "github.com/agglayer/aggkit/agglayer/types" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +func TestFormatTokenAmount(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + amount *big.Int + decimals uint8 + expected string + }{ + {"nil amount", nil, 18, "0"}, + {"zero decimals", big.NewInt(12345), 0, "12345 (raw)"}, + {"exact whole number", new(big.Int).Mul(big.NewInt(3), new(big.Int).Exp(big.NewInt(10), big.NewInt(18), nil)), 18, "3"}, + {"1.5 tokens", new(big.Int).SetUint64(1_500_000_000_000_000_000), 18, "1.5"}, + {"small sub-unit value (user case)", big.NewInt(4938271560), 18, "0.00000000493827156"}, + {"trailing zeros stripped", big.NewInt(1_500_000), 6, "1.5"}, + {"fractional only, no leading fraction digit trimmed", big.NewInt(1234567890), 6, "1234.56789"}, + {"zero amount", big.NewInt(0), 18, "0"}, + {"one wei", big.NewInt(1), 18, "0.000000000000000001"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + require.Equal(t, tc.expected, formatTokenAmount(tc.amount, tc.decimals)) + }) + } +} + +func TestDecodeBridgeEvent_Valid(t *testing.T) { + t.Parallel() + + data := make([]byte, 9*32) + + data[31] = 0 + data[63] = 1 + copy(data[64+12:96], common.HexToAddress("0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA").Bytes()) + data[127] = 2 + copy(data[128+12:160], common.HexToAddress("0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB").Bytes()) + new(big.Int).SetInt64(1000).FillBytes(data[160:192]) + new(big.Int).SetInt64(256).FillBytes(data[192:224]) + new(big.Int).SetInt64(42).FillBytes(data[224:256]) + new(big.Int).SetInt64(0).FillBytes(data[256:288]) + + dataHex := "0x" + common.Bytes2Hex(data) + dep, err := decodeBridgeEvent(dataHex, "0xa", "0x1234") + require.NoError(t, err) + + require.Equal(t, uint8(0), dep.LeafType) + require.Equal(t, uint32(1), dep.OriginNetwork) + require.Equal(t, common.HexToAddress("0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"), dep.OriginAddress) + require.Equal(t, uint32(2), dep.DestinationNetwork) + require.Equal(t, common.HexToAddress("0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"), dep.DestinationAddress) + require.Equal(t, big.NewInt(1000), dep.Amount) + require.Equal(t, uint32(42), dep.DepositCount) + require.Equal(t, uint64(10), dep.BlockNumber) +} + +func TestDecodeBridgeEvent_DataTooShort(t *testing.T) { + t.Parallel() + + _, err := decodeBridgeEvent("0x0000", "0x1", "0x1234") + require.Error(t, err) + require.Contains(t, err.Error(), "data too short") +} + +func TestEncodeIsClaimed(t *testing.T) { + t.Parallel() + + // isClaimed(leafIndex=42, sourceBridgeNetwork=0) + encoded := encodeIsClaimed(42, 0) + + require.Equal(t, "0xcc461632", encoded[:10]) + + // Next 32 bytes = leafIndex = 42 (0x2a) + require.Equal(t, "000000000000000000000000000000000000000000000000000000000000002a", encoded[10:74]) + + // Next 32 bytes = sourceBridgeNetwork = 0 + require.Equal(t, "0000000000000000000000000000000000000000000000000000000000000000", encoded[74:138]) +} + +func TestEncodeIsClaimed_NonZeroSource(t *testing.T) { + t.Parallel() + + encoded := encodeIsClaimed(100, 5) + + require.Equal(t, "0xcc461632", encoded[:10]) + require.Equal(t, "0000000000000000000000000000000000000000000000000000000000000064", encoded[10:74]) + require.Equal(t, "0000000000000000000000000000000000000000000000000000000000000005", encoded[74:138]) +} + +func TestParseClaimedResults(t *testing.T) { + t.Parallel() + + deposits := []L1Deposit{ + {DepositCount: 1}, + {DepositCount: 2}, + {DepositCount: 3}, + } + + trueHex := json.RawMessage(`"0x0000000000000000000000000000000000000000000000000000000000000001"`) + falseHex := json.RawMessage(`"0x0000000000000000000000000000000000000000000000000000000000000000"`) + + results := []json.RawMessage{trueHex, falseHex, trueHex} + + claimed := parseClaimedResults(results, deposits) + + require.Contains(t, claimed, uint32(1)) + require.NotContains(t, claimed, uint32(2)) + require.Contains(t, claimed, uint32(3)) +} + +func TestFilterUnclaimedDeposits(t *testing.T) { + t.Parallel() + + deposits := []L1Deposit{ + {DepositCount: 1, Amount: big.NewInt(100)}, + {DepositCount: 2, Amount: big.NewInt(200)}, + {DepositCount: 3, Amount: big.NewInt(300)}, + } + + claimed := map[uint32]struct{}{1: {}, 3: {}} + unclaimed := filterUnclaimedDeposits(deposits, claimed) + + require.Len(t, unclaimed, 1) + require.Equal(t, uint32(2), unclaimed[0].DepositCount) +} + +func TestReportPendingDiscrepancies_NoDiscrepancy(t *testing.T) { + t.Parallel() + + unclaimed := []L1Deposit{ + {DepositCount: 1}, + {DepositCount: 2}, + {DepositCount: 3}, + } + svcCounts := map[uint32]struct{}{1: {}, 2: {}, 3: {}} + + err := reportPendingDiscrepancies("test service", unclaimed, svcCounts) + require.NoError(t, err) +} + +func TestReportPendingDiscrepancies_BothEmpty(t *testing.T) { + t.Parallel() + + err := reportPendingDiscrepancies("test service", nil, map[uint32]struct{}{}) + require.NoError(t, err) +} + +func TestReportPendingDiscrepancies_InSvcOnly(t *testing.T) { + t.Parallel() + + unclaimed := []L1Deposit{{DepositCount: 1}} + svcCounts := map[uint32]struct{}{1: {}, 5: {}, 9: {}} + + err := reportPendingDiscrepancies("test service", unclaimed, svcCounts) + require.Error(t, err) + require.Contains(t, err.Error(), "test service reports 2 deposit(s) not found by L1 scan") + require.Contains(t, err.Error(), "[5 9]") + require.NotContains(t, err.Error(), "L1 scan found") +} + +func TestReportPendingDiscrepancies_InScanOnly(t *testing.T) { + t.Parallel() + + unclaimed := []L1Deposit{{DepositCount: 1}, {DepositCount: 7}, {DepositCount: 3}} + svcCounts := map[uint32]struct{}{1: {}} + + err := reportPendingDiscrepancies("test service", unclaimed, svcCounts) + require.Error(t, err) + require.Contains(t, err.Error(), "L1 scan found 2 deposit(s) not reported by test service") + require.Contains(t, err.Error(), "[3 7]") + require.NotContains(t, err.Error(), "test service reports") +} + +func TestReportPendingDiscrepancies_BothSides(t *testing.T) { + t.Parallel() + + unclaimed := []L1Deposit{{DepositCount: 1}, {DepositCount: 2}} + svcCounts := map[uint32]struct{}{1: {}, 99: {}} + + err := reportPendingDiscrepancies("test service", unclaimed, svcCounts) + require.Error(t, err) + require.Contains(t, err.Error(), "test service reports 1 deposit(s) not found by L1 scan") + require.Contains(t, err.Error(), "L1 scan found 1 deposit(s) not reported by test service") +} + +func TestStepE_MergeCertificateExits(t *testing.T) { + t.Parallel() + + existingExit := &agglayertypes.BridgeExit{ + TokenInfo: &agglayertypes.TokenInfo{ + OriginNetwork: 0, + OriginTokenAddress: common.Address{}, + }, + DestinationNetwork: 0, + DestinationAddress: common.HexToAddress("0x1111"), + Amount: big.NewInt(100), + } + + newExit := &agglayertypes.BridgeExit{ + TokenInfo: &agglayertypes.TokenInfo{ + OriginNetwork: 0, + OriginTokenAddress: common.Address{}, + }, + DestinationNetwork: 0, + DestinationAddress: common.HexToAddress("0x2222"), + Amount: big.NewInt(200), + } + + certificate := &agglayertypes.Certificate{ + NetworkID: 1, + BridgeExits: []*agglayertypes.BridgeExit{existingExit}, + } + + finalCert := mergeCertificate(certificate, []*agglayertypes.BridgeExit{newExit}, nil) + + require.Len(t, finalCert.BridgeExits, 2) + require.Equal(t, big.NewInt(100), finalCert.BridgeExits[0].Amount) + require.Equal(t, big.NewInt(200), finalCert.BridgeExits[1].Amount) +} diff --git a/tools/exit_certificate/step_f.go b/tools/exit_certificate/step_f.go new file mode 100644 index 000000000..0ccfc8126 --- /dev/null +++ b/tools/exit_certificate/step_f.go @@ -0,0 +1,445 @@ +package exit_certificate + +import ( + "context" + "encoding/json" + "fmt" + "math/big" + "sort" + + agglayertypes "github.com/agglayer/aggkit/agglayer/types" + "github.com/agglayer/aggkit/log" + "github.com/ethereum/go-ethereum/common" +) + +// agglayerTokenEntry is a single entry from admin_getTokenBalance response. +type agglayerTokenEntry struct { + OriginNetwork uint32 `json:"originNetwork"` + OriginTokenAddress common.Address `json:"originTokenAddress"` + Amount string `json:"amount"` // decimal U256 +} + +// agglayerBalanceResponse is the full admin_getTokenBalance JSON response. +type agglayerBalanceResponse struct { + Balances []agglayerTokenEntry `json:"balances"` +} + +// tokenKey identifies a token uniquely. +type tokenKey struct { + OriginNetwork uint32 + OriginTokenAddress common.Address +} + +// RunStepF verifies the certificate's per-token bridge-exit sums. +// +// When useAgglayerAdminToStepFCheck is true (the default) it queries the agglayer admin API for token +// balances and performs a three-way comparison: LBT (Step 0 total supplies) == agglayer balance == +// sum of certificate bridge exits. agglayerAdminURL is required. lbtEntries may be nil, in which case +// it falls back to a two-way agglayer-vs-certificate comparison. +// +// When useAgglayerAdminToStepFCheck is false it skips the agglayer admin query and instead runs an +// offline two-way comparison of the LBT (Step 0) totals against the certificate bridge-exit sums (see +// runStepFOfflineLBT). When no LBT data is available there is nothing to compare and the step is skipped. +func RunStepF( + ctx context.Context, cfg *Config, + certificate *agglayertypes.Certificate, + lbtEntries []LBTEntry, +) (*StepFResult, error) { + log.Info("═══════════════════════════════════════════") + log.Info(" STEP F — Agglayer token balance check") + log.Info("═══════════════════════════════════════════") + + // The agglayer admin query is opt-out. When disabled we still run an offline LBT vs certificate + // comparison instead of skipping the step outright. + if !cfg.Options.UseAgglayerAdminToStepFCheck { + return runStepFOfflineLBT(cfg, certificate, lbtEntries) + } + + if cfg.Options.AgglayerAdminURL == "" { + return nil, fmt.Errorf("step F requires agglayerAdminURL to be set in options") + } + + log.Infof("Querying %s (network %d)", cfg.Options.AgglayerAdminURL, cfg.L2NetworkID) + if cfg.Options.AgglayerAdminToken != "" { + log.Info("Using bearer token for agglayer admin authentication") + } + + raw, err := singleRPCAuth( + ctx, cfg.Options.AgglayerAdminURL, + "admin_getTokenBalance", + []any{cfg.L2NetworkID, nil}, + defaultRetries, + cfg.Options.AgglayerAdminToken, + ) + if err != nil { + return nil, fmt.Errorf("admin_getTokenBalance (network %d): %w", cfg.L2NetworkID, err) + } + + var agglayerResp agglayerBalanceResponse + if err := json.Unmarshal(raw, &agglayerResp); err != nil { + return nil, fmt.Errorf("parse admin_getTokenBalance response: %w", err) + } + + groups := groupBridgeExitsByToken(certificate) + checks := compareTokenBalances(groups, agglayerResp.Balances, lbtEntries) + + allMatch := true + for _, c := range checks { + if !c.Match { + allMatch = false + if c.LBTAmount != "" { + log.Warnf("❌ MISMATCH (network=%d addr=%s): lbt=%s certificate=%s agglayer=%s", + c.OriginNetwork, c.OriginTokenAddress, c.LBTAmount, c.CertificateAmount, c.AgglayerAmount) + } else { + log.Warnf("❌ MISMATCH (network=%d addr=%s): certificate=%s agglayer=%s", + c.OriginNetwork, c.OriginTokenAddress, c.CertificateAmount, c.AgglayerAmount) + } + for i, e := range c.CertificateEntries { + log.Infof(" ⚠️ [%d] dest_network=%d dest=%s amount=%s", + i, e.DestinationNetwork, e.DestinationAddress, e.Amount) + } + } else { + if c.LBTAmount != "" { + log.Infof("✅ (network=%d addr=%s): lbt=%s certificate=%s agglayer=%s", + c.OriginNetwork, c.OriginTokenAddress, c.LBTAmount, c.CertificateAmount, c.AgglayerAmount) + } else { + log.Infof("✅ (network=%d addr=%s): certificate=%s agglayer=%s", + c.OriginNetwork, c.OriginTokenAddress, c.CertificateAmount, c.AgglayerAmount) + } + } + } + if allMatch { + if lbtEntries != nil { + log.Infof("All %d token balances match ✅ LBT = agglayer = certificate", len(checks)) + } else { + log.Infof("All %d token balances match agglayer state ✅", len(checks)) + } + } + + log.Info("STEP F complete") + + return finalizeStepFResult(cfg, certificate, checks, raw, allMatch) +} + +// runStepFOfflineLBT runs Step F without contacting the agglayer admin API +// (useAgglayerAdminToStepFCheck=false): it compares the LBT (Step 0) totals against the certificate +// bridge-exit sums per token. When no LBT data is available there is nothing to compare and the step +// is skipped with a benign all-match result. +func runStepFOfflineLBT( + cfg *Config, certificate *agglayertypes.Certificate, lbtEntries []LBTEntry, +) (*StepFResult, error) { + if len(lbtEntries) == 0 { + log.Warn("STEP F skipped: useAgglayerAdminToStepFCheck=false and no LBT data available for the offline check") + return &StepFResult{AllMatch: true}, nil + } + + log.Info("useAgglayerAdminToStepFCheck=false — comparing LBT (step 0) vs certificate bridge exits (no agglayer query)") + groups := groupBridgeExitsByToken(certificate) + checks := compareCertificateToLBT(groups, lbtEntries) + + allMatch := true + for _, c := range checks { + if !c.Match { + allMatch = false + log.Warnf("❌ MISMATCH (network=%d addr=%s): lbt=%s certificate=%s", + c.OriginNetwork, c.OriginTokenAddress, c.LBTAmount, c.CertificateAmount) + for i, e := range c.CertificateEntries { + log.Infof(" ⚠️ [%d] dest_network=%d dest=%s amount=%s", + i, e.DestinationNetwork, e.DestinationAddress, e.Amount) + } + } else { + log.Infof("✅ (network=%d addr=%s): lbt=%s certificate=%s", + c.OriginNetwork, c.OriginTokenAddress, c.LBTAmount, c.CertificateAmount) + } + } + if allMatch { + log.Infof("All %d token balances match ✅ LBT = certificate", len(checks)) + } + log.Info("STEP F complete (offline LBT check)") + + return finalizeStepFResult(cfg, certificate, checks, nil, allMatch) +} + +// finalizeStepFResult assembles the StepFResult from the comparison checks, applying the +// ignoreBalanceMismatch policy: on a mismatch it either caps the certificate's bridge exits to each +// token's RemainingBalance (ignoreBalanceMismatch=true) or returns an error. raw is the agglayer admin +// response when available (nil for the offline LBT-only check). +func finalizeStepFResult( + cfg *Config, certificate *agglayertypes.Certificate, + checks []TokenBalanceCheck, raw json.RawMessage, allMatch bool, +) (*StepFResult, error) { + result := &StepFResult{ + AllMatch: allMatch, + TokenBalances: raw, + Checks: checks, + } + if allMatch { + return result, nil + } + if !cfg.Options.IgnoreBalanceMismatch { + return result, fmt.Errorf("token balance mismatches detected (set options.ignoreBalanceMismatch=true to ignore)") + } + + log.Warn("Balance mismatches detected — continuing anyway (ignoreBalanceMismatch=true)") + for _, c := range checks { + if !c.Match { + log.Debugf(" ⚠️ check: network=%d addr=%s lbt=%s certificate=%s agglayer=%s match=%v", + c.OriginNetwork, c.OriginTokenAddress, c.LBTAmount, c.CertificateAmount, c.AgglayerAmount, c.Match) + } + } + capped := *certificate + capped.BridgeExits = capCertificateExits(certificate.BridgeExits, checks) + result.CappedCertificate = &capped + log.Infof("🔧 Capped certificate: %d → %d bridge exits", + len(certificate.BridgeExits), len(capped.BridgeExits)) + return result, nil +} + +// groupBridgeExitsByToken groups bridge exits from the certificate by TokenInfo. +func groupBridgeExitsByToken(cert *agglayertypes.Certificate) map[tokenKey][]*agglayertypes.BridgeExit { + groups := make(map[tokenKey][]*agglayertypes.BridgeExit) + if cert == nil { + return groups + } + for _, exit := range cert.BridgeExits { + if exit == nil || exit.TokenInfo == nil || exit.Amount == nil { + continue + } + k := tokenKey{exit.TokenInfo.OriginNetwork, exit.TokenInfo.OriginTokenAddress} + groups[k] = append(groups[k], exit) + } + return groups +} + +// compareTokenBalances builds the per-token three-way comparison list. +// When lbtEntries is non-nil, match requires LBT == agglayer == certificate sum. +// When lbtEntries is nil, match requires agglayer == certificate sum (two-way fallback). +// CertificateEntries is populated only on mismatch. +func compareTokenBalances( + groups map[tokenKey][]*agglayertypes.BridgeExit, + agglayerEntries []agglayerTokenEntry, + lbtEntries []LBTEntry, +) []TokenBalanceCheck { + agglayerMap := make(map[tokenKey]*big.Int, len(agglayerEntries)) + for _, e := range agglayerEntries { + k := tokenKey{e.OriginNetwork, e.OriginTokenAddress} + amount, ok := new(big.Int).SetString(e.Amount, decimalBase) + if !ok { + log.Warnf("Could not parse agglayer amount %q for token (network=%d addr=%s)", + e.Amount, e.OriginNetwork, e.OriginTokenAddress.Hex()) + continue + } + agglayerMap[k] = amount + } + + lbtMap := make(map[tokenKey]*big.Int, len(lbtEntries)) + for _, e := range lbtEntries { + k := tokenKey{e.OriginNetwork, e.OriginTokenAddress} + amount, ok := new(big.Int).SetString(e.Balance, decimalBase) + if !ok { + log.Warnf("Could not parse LBT balance %q for token (network=%d addr=%s)", + e.Balance, e.OriginNetwork, e.OriginTokenAddress.Hex()) + continue + } + lbtMap[k] = amount + } + + seen := make(map[tokenKey]struct{}, len(groups)+len(agglayerMap)+len(lbtMap)) + for k := range groups { + seen[k] = struct{}{} + } + for k := range agglayerMap { + seen[k] = struct{}{} + } + for k := range lbtMap { + seen[k] = struct{}{} + } + + hasLBT := lbtEntries != nil + + checks := make([]TokenBalanceCheck, 0, len(seen)) + for k := range seen { + exits := groups[k] + certAmt := new(big.Int) + for _, e := range exits { + certAmt.Add(certAmt, e.Amount) + } + + agglAmt := agglayerMap[k] + if agglAmt == nil { + agglAmt = new(big.Int) + } + + check := TokenBalanceCheck{ + OriginNetwork: k.OriginNetwork, + OriginTokenAddress: k.OriginTokenAddress.Hex(), + CertificateAmount: certAmt.String(), + AgglayerAmount: agglAmt.String(), + } + + if hasLBT { + lbtAmt := lbtMap[k] + if lbtAmt == nil { + lbtAmt = new(big.Int) + } + check.LBTAmount = lbtAmt.String() + check.Match = certAmt.Cmp(agglAmt) == 0 && agglAmt.Cmp(lbtAmt) == 0 + if agglAmt.Cmp(lbtAmt) <= 0 { + check.RemainingBalance = new(big.Int).Set(agglAmt) + } else { + check.RemainingBalance = new(big.Int).Set(lbtAmt) + } + } else { + check.Match = certAmt.Cmp(agglAmt) == 0 + check.RemainingBalance = new(big.Int).Set(agglAmt) + } + + if !check.Match { + check.CertificateEntries = make([]CertificateEntry, len(exits)) + for i, e := range exits { + check.CertificateEntries[i] = CertificateEntry{ + DestinationNetwork: e.DestinationNetwork, + DestinationAddress: e.DestinationAddress.Hex(), + Amount: e.Amount.String(), + } + } + } + checks = append(checks, check) + } + + sort.Slice(checks, func(i, j int) bool { + if checks[i].OriginNetwork != checks[j].OriginNetwork { + return checks[i].OriginNetwork < checks[j].OriginNetwork + } + return checks[i].OriginTokenAddress < checks[j].OriginTokenAddress + }) + return checks +} + +// compareCertificateToLBT builds a per-token comparison of the certificate bridge-exit sums against +// the LBT (Step 0) totals, without any agglayer data (used when useAgglayerAdminToStepFCheck=false). +// Match requires certificate sum == LBT total per token; AgglayerAmount is left empty. RemainingBalance +// is the LBT total, used as the cap budget when ignoreBalanceMismatch is set. CertificateEntries is +// populated only on mismatch. +func compareCertificateToLBT( + groups map[tokenKey][]*agglayertypes.BridgeExit, lbtEntries []LBTEntry, +) []TokenBalanceCheck { + lbtMap := make(map[tokenKey]*big.Int, len(lbtEntries)) + for _, e := range lbtEntries { + k := tokenKey{e.OriginNetwork, e.OriginTokenAddress} + amount, ok := new(big.Int).SetString(e.Balance, decimalBase) + if !ok { + log.Warnf("Could not parse LBT balance %q for token (network=%d addr=%s)", + e.Balance, e.OriginNetwork, e.OriginTokenAddress.Hex()) + continue + } + lbtMap[k] = amount + } + + seen := make(map[tokenKey]struct{}, len(groups)+len(lbtMap)) + for k := range groups { + seen[k] = struct{}{} + } + for k := range lbtMap { + seen[k] = struct{}{} + } + + checks := make([]TokenBalanceCheck, 0, len(seen)) + for k := range seen { + exits := groups[k] + certAmt := new(big.Int) + for _, e := range exits { + certAmt.Add(certAmt, e.Amount) + } + + lbtAmt := lbtMap[k] + if lbtAmt == nil { + lbtAmt = new(big.Int) + } + + check := TokenBalanceCheck{ + OriginNetwork: k.OriginNetwork, + OriginTokenAddress: k.OriginTokenAddress.Hex(), + LBTAmount: lbtAmt.String(), + CertificateAmount: certAmt.String(), + Match: certAmt.Cmp(lbtAmt) == 0, + RemainingBalance: new(big.Int).Set(lbtAmt), + } + + if !check.Match { + check.CertificateEntries = make([]CertificateEntry, len(exits)) + for i, e := range exits { + check.CertificateEntries[i] = CertificateEntry{ + DestinationNetwork: e.DestinationNetwork, + DestinationAddress: e.DestinationAddress.Hex(), + Amount: e.Amount.String(), + } + } + } + checks = append(checks, check) + } + + sort.Slice(checks, func(i, j int) bool { + if checks[i].OriginNetwork != checks[j].OriginNetwork { + return checks[i].OriginNetwork < checks[j].OriginNetwork + } + return checks[i].OriginTokenAddress < checks[j].OriginTokenAddress + }) + return checks +} + +// capCertificateExits returns a new slice of bridge exits trimmed to stay within each +// token's RemainingBalance (= min(LBT, agglayer) from its TokenBalanceCheck). +// Exits are processed in order: each exit's amount is deducted from the token budget. +// An exit that would exceed the budget is capped to the remaining amount. +// Exits with a resulting zero amount are dropped. +func capCertificateExits(exits []*agglayertypes.BridgeExit, checks []TokenBalanceCheck) []*agglayertypes.BridgeExit { + remaining := make(map[tokenKey]*big.Int, len(checks)) + for _, c := range checks { + if c.RemainingBalance == nil { + continue + } + k := tokenKey{c.OriginNetwork, common.HexToAddress(c.OriginTokenAddress)} + remaining[k] = new(big.Int).Set(c.RemainingBalance) + } + + result := make([]*agglayertypes.BridgeExit, 0, len(exits)) + for _, e := range exits { + if e == nil || e.TokenInfo == nil || e.Amount == nil { + result = append(result, e) + continue + } + k := tokenKey{e.TokenInfo.OriginNetwork, e.TokenInfo.OriginTokenAddress} + rem, hasCap := remaining[k] + if !hasCap { + result = append(result, e) + continue + } + if rem.Sign() == 0 { + log.Debugf("🔧 Drop bridge exit (network=%d addr=%s amount=%s): no budget left", + k.OriginNetwork, k.OriginTokenAddress, e.Amount) + continue + } + if e.Amount.Cmp(rem) <= 0 { + rem.Sub(rem, e.Amount) + result = append(result, e) + } else { + exitCopy := *e + if e.TokenInfo != nil { + tc := *e.TokenInfo + exitCopy.TokenInfo = &tc + } + if e.Metadata != nil { + md := make([]byte, len(e.Metadata)) + copy(md, e.Metadata) + exitCopy.Metadata = md + } + log.Infof("🔧 Cap bridge exit (network=%d addr=%s): %s → %s", + k.OriginNetwork, k.OriginTokenAddress, e.Amount, rem) + exitCopy.Amount = new(big.Int).Set(rem) + rem.SetInt64(0) + result = append(result, &exitCopy) + } + } + return result +} diff --git a/tools/exit_certificate/step_f_test.go b/tools/exit_certificate/step_f_test.go new file mode 100644 index 000000000..2b74ee603 --- /dev/null +++ b/tools/exit_certificate/step_f_test.go @@ -0,0 +1,444 @@ +package exit_certificate + +import ( + "context" + "encoding/json" + "math/big" + "net/http" + "net/http/httptest" + "testing" + + agglayertypes "github.com/agglayer/aggkit/agglayer/types" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +func TestRunStepF_WithBearerToken(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "Bearer my-iap-token", r.Header.Get("Authorization")) + resp := jsonRPCResponse{ + JSONRPC: "2.0", ID: 1, + Result: json.RawMessage(`{"balances":[]}`), + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + cfg := &Config{ + L2NetworkID: 1, + Options: Options{ + UseAgglayerAdminToStepFCheck: true, + AgglayerAdminURL: server.URL, + AgglayerAdminToken: "my-iap-token", + }, + } + result, err := RunStepF(context.Background(), cfg, &agglayertypes.Certificate{}, nil) + require.NoError(t, err) + require.NotNil(t, result) +} + +func TestRunStepF_MissingAdminURL_Error(t *testing.T) { + t.Parallel() + + cfg := &Config{Options: Options{UseAgglayerAdminToStepFCheck: true}} + _, err := RunStepF(context.Background(), cfg, &agglayertypes.Certificate{}, nil) + require.Error(t, err) + require.Contains(t, err.Error(), "agglayerAdminURL") +} + +func TestRunStepF_DisabledNoLBT_Skips(t *testing.T) { + t.Parallel() + + // useAgglayerAdminToStepFCheck=false and no LBT data: nothing to compare, so the step is + // skipped with a benign all-match result and no RPC call (no agglayerAdminURL set). + cfg := &Config{Options: Options{UseAgglayerAdminToStepFCheck: false}} + result, err := RunStepF(context.Background(), cfg, &agglayertypes.Certificate{}, nil) + require.NoError(t, err) + require.NotNil(t, result) + require.True(t, result.AllMatch) + require.Nil(t, result.CappedCertificate) + require.Nil(t, result.TokenBalances) +} + +func TestRunStepF_DisabledWithLBT_MatchOffline(t *testing.T) { + t.Parallel() + + // useAgglayerAdminToStepFCheck=false but LBT data is available: compare LBT (step 0) totals + // against the certificate bridge-exit sums, with no agglayer query and no agglayerAdminURL. + addr := common.HexToAddress("0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") + cert := &agglayertypes.Certificate{ + BridgeExits: []*agglayertypes.BridgeExit{ + { + TokenInfo: &agglayertypes.TokenInfo{OriginNetwork: 0, OriginTokenAddress: addr}, + Amount: big.NewInt(1000), + DestinationAddress: common.HexToAddress("0xBBBB"), + }, + }, + } + lbt := []LBTEntry{{OriginNetwork: 0, OriginTokenAddress: addr, Balance: "1000"}} + + cfg := &Config{Options: Options{UseAgglayerAdminToStepFCheck: false}} + result, err := RunStepF(context.Background(), cfg, cert, lbt) + require.NoError(t, err) + require.True(t, result.AllMatch) + require.Nil(t, result.TokenBalances) + require.Len(t, result.Checks, 1) + require.Equal(t, "1000", result.Checks[0].LBTAmount) + require.Equal(t, "1000", result.Checks[0].CertificateAmount) + require.Empty(t, result.Checks[0].AgglayerAmount) +} + +func TestRunStepF_DisabledWithLBT_MismatchAborts(t *testing.T) { + t.Parallel() + + addr := common.HexToAddress("0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") + cert := &agglayertypes.Certificate{ + BridgeExits: []*agglayertypes.BridgeExit{ + { + TokenInfo: &agglayertypes.TokenInfo{OriginNetwork: 0, OriginTokenAddress: addr}, + Amount: big.NewInt(1000), + DestinationAddress: common.HexToAddress("0xBBBB"), + }, + }, + } + lbt := []LBTEntry{{OriginNetwork: 0, OriginTokenAddress: addr, Balance: "500"}} + + cfg := &Config{Options: Options{UseAgglayerAdminToStepFCheck: false}} + _, err := RunStepF(context.Background(), cfg, cert, lbt) + require.Error(t, err) + require.Contains(t, err.Error(), "mismatch") +} + +func TestRunStepF_DisabledWithLBT_MismatchCaps(t *testing.T) { + t.Parallel() + + addr := common.HexToAddress("0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") + cert := &agglayertypes.Certificate{ + BridgeExits: []*agglayertypes.BridgeExit{ + { + TokenInfo: &agglayertypes.TokenInfo{OriginNetwork: 0, OriginTokenAddress: addr}, + Amount: big.NewInt(1000), + DestinationAddress: common.HexToAddress("0xBBBB"), + }, + }, + } + lbt := []LBTEntry{{OriginNetwork: 0, OriginTokenAddress: addr, Balance: "500"}} + + cfg := &Config{Options: Options{UseAgglayerAdminToStepFCheck: false, IgnoreBalanceMismatch: true}} + result, err := RunStepF(context.Background(), cfg, cert, lbt) + require.NoError(t, err) + require.False(t, result.AllMatch) + require.NotNil(t, result.CappedCertificate) +} + +func TestRunStepF_AllMatch(t *testing.T) { + t.Parallel() + + addr := common.HexToAddress("0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := jsonRPCResponse{ + JSONRPC: "2.0", ID: 1, + Result: json.RawMessage(`{"balances":[{"originNetwork":0,"originTokenAddress":"0xaAaAaAaaAaAaAaaAaAAAAAAAAaaaAaAaAaaAaaAa","amount":"1000"}]}`), + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + cert := &agglayertypes.Certificate{ + BridgeExits: []*agglayertypes.BridgeExit{ + { + TokenInfo: &agglayertypes.TokenInfo{OriginNetwork: 0, OriginTokenAddress: addr}, + Amount: big.NewInt(1000), + DestinationAddress: common.HexToAddress("0xBBBB"), + }, + }, + } + lbt := []LBTEntry{{OriginNetwork: 0, OriginTokenAddress: addr, Balance: "1000"}} + + cfg := &Config{L2NetworkID: 0, Options: Options{UseAgglayerAdminToStepFCheck: true, AgglayerAdminURL: server.URL}} + result, err := RunStepF(context.Background(), cfg, cert, lbt) + require.NoError(t, err) + require.True(t, result.AllMatch) + require.Nil(t, result.CappedCertificate) +} + +func TestRunStepF_MismatchAborts(t *testing.T) { + t.Parallel() + + addr := common.HexToAddress("0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := jsonRPCResponse{ + JSONRPC: "2.0", ID: 1, + Result: json.RawMessage(`{"balances":[{"originNetwork":0,"originTokenAddress":"0xaAaAaAaaAaAaAaaAaAAAAAAAAaaaAaAaAaaAaaAa","amount":"500"}]}`), + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + cert := &agglayertypes.Certificate{ + BridgeExits: []*agglayertypes.BridgeExit{ + { + TokenInfo: &agglayertypes.TokenInfo{OriginNetwork: 0, OriginTokenAddress: addr}, + Amount: big.NewInt(1000), + DestinationAddress: common.HexToAddress("0xBBBB"), + }, + }, + } + cfg := &Config{L2NetworkID: 0, Options: Options{UseAgglayerAdminToStepFCheck: true, AgglayerAdminURL: server.URL}} + _, err := RunStepF(context.Background(), cfg, cert, nil) + require.Error(t, err) + require.Contains(t, err.Error(), "mismatch") +} + +func TestRunStepF_MismatchContinues(t *testing.T) { + t.Parallel() + + addr := common.HexToAddress("0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := jsonRPCResponse{ + JSONRPC: "2.0", ID: 1, + Result: json.RawMessage(`{"balances":[{"originNetwork":0,"originTokenAddress":"0xaAaAaAaaAaAaAaaAaAAAAAAAAaaaAaAaAaaAaaAa","amount":"500"}]}`), + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + cert := &agglayertypes.Certificate{ + BridgeExits: []*agglayertypes.BridgeExit{ + { + TokenInfo: &agglayertypes.TokenInfo{OriginNetwork: 0, OriginTokenAddress: addr}, + Amount: big.NewInt(1000), + DestinationAddress: common.HexToAddress("0xBBBB"), + }, + }, + } + cfg := &Config{ + L2NetworkID: 0, + Options: Options{ + UseAgglayerAdminToStepFCheck: true, + AgglayerAdminURL: server.URL, + IgnoreBalanceMismatch: true, + }, + } + result, err := RunStepF(context.Background(), cfg, cert, nil) + require.NoError(t, err) + require.False(t, result.AllMatch) + require.NotNil(t, result.CappedCertificate) +} + +func TestRunStepF_RPCError(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + cfg := &Config{L2NetworkID: 1, Options: Options{UseAgglayerAdminToStepFCheck: true, AgglayerAdminURL: server.URL}} + _, err := RunStepF(context.Background(), cfg, &agglayertypes.Certificate{}, nil) + require.Error(t, err) +} + +func TestGroupBridgeExitsByToken(t *testing.T) { + t.Parallel() + + addr1 := common.HexToAddress("0x1111111111111111111111111111111111111111") + addr2 := common.HexToAddress("0x2222222222222222222222222222222222222222") + + cert := &agglayertypes.Certificate{ + BridgeExits: []*agglayertypes.BridgeExit{ + {TokenInfo: &agglayertypes.TokenInfo{OriginNetwork: 0, OriginTokenAddress: addr1}, Amount: big.NewInt(100)}, + {TokenInfo: &agglayertypes.TokenInfo{OriginNetwork: 0, OriginTokenAddress: addr1}, Amount: big.NewInt(200)}, + {TokenInfo: &agglayertypes.TokenInfo{OriginNetwork: 1, OriginTokenAddress: addr2}, Amount: big.NewInt(500)}, + }, + } + + groups := groupBridgeExitsByToken(cert) + + require.Len(t, groups[tokenKey{0, addr1}], 2) + require.Len(t, groups[tokenKey{1, addr2}], 1) +} + +func TestCompareTokenBalances_AllMatch(t *testing.T) { + t.Parallel() + + addr := common.HexToAddress("0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") + dest := common.HexToAddress("0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB") + groups := map[tokenKey][]*agglayertypes.BridgeExit{ + {0, addr}: { + {TokenInfo: &agglayertypes.TokenInfo{OriginNetwork: 0, OriginTokenAddress: addr}, DestinationAddress: dest, Amount: big.NewInt(1000)}, + }, + } + agglayerEntries := []agglayerTokenEntry{ + {OriginNetwork: 0, OriginTokenAddress: addr, Amount: "1000"}, + } + + checks := compareTokenBalances(groups, agglayerEntries, nil) + require.Len(t, checks, 1) + require.True(t, checks[0].Match) + require.Empty(t, checks[0].CertificateEntries) +} + +func TestCompareTokenBalances_Mismatch(t *testing.T) { + t.Parallel() + + addr := common.HexToAddress("0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") + dest1 := common.HexToAddress("0x1111111111111111111111111111111111111111") + dest2 := common.HexToAddress("0x2222222222222222222222222222222222222222") + groups := map[tokenKey][]*agglayertypes.BridgeExit{ + {0, addr}: { + {TokenInfo: &agglayertypes.TokenInfo{OriginNetwork: 0, OriginTokenAddress: addr}, DestinationAddress: dest1, DestinationNetwork: 0, Amount: big.NewInt(600)}, + {TokenInfo: &agglayertypes.TokenInfo{OriginNetwork: 0, OriginTokenAddress: addr}, DestinationAddress: dest2, DestinationNetwork: 0, Amount: big.NewInt(400)}, + }, + } + agglayerEntries := []agglayerTokenEntry{ + {OriginNetwork: 0, OriginTokenAddress: addr, Amount: "999"}, + } + + checks := compareTokenBalances(groups, agglayerEntries, nil) + require.Len(t, checks, 1) + require.False(t, checks[0].Match) + require.Equal(t, "1000", checks[0].CertificateAmount) + require.Equal(t, "999", checks[0].AgglayerAmount) + require.Len(t, checks[0].CertificateEntries, 2) + require.Equal(t, "600", checks[0].CertificateEntries[0].Amount) + require.Equal(t, "400", checks[0].CertificateEntries[1].Amount) + require.Equal(t, big.NewInt(999), checks[0].RemainingBalance) +} + +func TestCompareTokenBalances_MissingInAgglayer(t *testing.T) { + t.Parallel() + + addr := common.HexToAddress("0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") + dest := common.HexToAddress("0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB") + groups := map[tokenKey][]*agglayertypes.BridgeExit{ + {0, addr}: { + {TokenInfo: &agglayertypes.TokenInfo{OriginNetwork: 0, OriginTokenAddress: addr}, DestinationAddress: dest, Amount: big.NewInt(500)}, + }, + } + + checks := compareTokenBalances(groups, nil, nil) + require.Len(t, checks, 1) + require.False(t, checks[0].Match) + require.Equal(t, "500", checks[0].CertificateAmount) + require.Equal(t, "0", checks[0].AgglayerAmount) + require.Len(t, checks[0].CertificateEntries, 1) + require.Equal(t, big.NewInt(0), checks[0].RemainingBalance) +} + +func TestCapCertificateExits_FitsWithinBudget(t *testing.T) { + t.Parallel() + + addr := common.HexToAddress("0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") + exits := []*agglayertypes.BridgeExit{ + {TokenInfo: &agglayertypes.TokenInfo{OriginNetwork: 0, OriginTokenAddress: addr}, Amount: big.NewInt(400)}, + {TokenInfo: &agglayertypes.TokenInfo{OriginNetwork: 0, OriginTokenAddress: addr}, Amount: big.NewInt(300)}, + } + checks := []TokenBalanceCheck{ + {OriginNetwork: 0, OriginTokenAddress: addr.Hex(), RemainingBalance: big.NewInt(1000)}, + } + + result := capCertificateExits(exits, checks) + require.Len(t, result, 2) + require.Equal(t, big.NewInt(400), result[0].Amount) + require.Equal(t, big.NewInt(300), result[1].Amount) +} + +func TestCapCertificateExits_CapsLastExit(t *testing.T) { + t.Parallel() + + addr := common.HexToAddress("0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") + exits := []*agglayertypes.BridgeExit{ + {TokenInfo: &agglayertypes.TokenInfo{OriginNetwork: 0, OriginTokenAddress: addr}, Amount: big.NewInt(600)}, + {TokenInfo: &agglayertypes.TokenInfo{OriginNetwork: 0, OriginTokenAddress: addr}, Amount: big.NewInt(400)}, + } + // Budget covers first exit fully; second must be capped to 300. + checks := []TokenBalanceCheck{ + {OriginNetwork: 0, OriginTokenAddress: addr.Hex(), RemainingBalance: big.NewInt(900)}, + } + + result := capCertificateExits(exits, checks) + require.Len(t, result, 2) + require.Equal(t, big.NewInt(600), result[0].Amount) + require.Equal(t, big.NewInt(300), result[1].Amount) +} + +func TestCapCertificateExits_DropsExitsWhenBudgetExhausted(t *testing.T) { + t.Parallel() + + addr := common.HexToAddress("0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") + exits := []*agglayertypes.BridgeExit{ + {TokenInfo: &agglayertypes.TokenInfo{OriginNetwork: 0, OriginTokenAddress: addr}, Amount: big.NewInt(500)}, + {TokenInfo: &agglayertypes.TokenInfo{OriginNetwork: 0, OriginTokenAddress: addr}, Amount: big.NewInt(500)}, + } + // Budget only covers first exit exactly; second must be dropped. + checks := []TokenBalanceCheck{ + {OriginNetwork: 0, OriginTokenAddress: addr.Hex(), RemainingBalance: big.NewInt(500)}, + } + + result := capCertificateExits(exits, checks) + require.Len(t, result, 1) + require.Equal(t, big.NewInt(500), result[0].Amount) +} + +func TestCapCertificateExits_ZeroBudgetDropsAll(t *testing.T) { + t.Parallel() + + addr := common.HexToAddress("0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") + exits := []*agglayertypes.BridgeExit{ + {TokenInfo: &agglayertypes.TokenInfo{OriginNetwork: 0, OriginTokenAddress: addr}, Amount: big.NewInt(100)}, + } + checks := []TokenBalanceCheck{ + {OriginNetwork: 0, OriginTokenAddress: addr.Hex(), RemainingBalance: big.NewInt(0)}, + } + + result := capCertificateExits(exits, checks) + require.Empty(t, result) +} + +func TestCapCertificateExits_TokenNotInChecksPassesThrough(t *testing.T) { + t.Parallel() + + addr := common.HexToAddress("0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") + exits := []*agglayertypes.BridgeExit{ + {TokenInfo: &agglayertypes.TokenInfo{OriginNetwork: 0, OriginTokenAddress: addr}, Amount: big.NewInt(999)}, + } + + result := capCertificateExits(exits, nil) + require.Len(t, result, 1) + require.Equal(t, big.NewInt(999), result[0].Amount) +} + +func TestCapCertificateExits_LBTMinAgglayer(t *testing.T) { + t.Parallel() + + addr := common.HexToAddress("0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") + // LBT=700, agglayer=800 → min=700; cert has two exits totalling 1000. + groups := map[tokenKey][]*agglayertypes.BridgeExit{ + {0, addr}: { + {TokenInfo: &agglayertypes.TokenInfo{OriginNetwork: 0, OriginTokenAddress: addr}, Amount: big.NewInt(600)}, + {TokenInfo: &agglayertypes.TokenInfo{OriginNetwork: 0, OriginTokenAddress: addr}, Amount: big.NewInt(400)}, + }, + } + checks := compareTokenBalances(groups, []agglayerTokenEntry{ + {OriginNetwork: 0, OriginTokenAddress: addr, Amount: "800"}, + }, []LBTEntry{ + {OriginNetwork: 0, OriginTokenAddress: addr, Balance: "700"}, + }) + require.Equal(t, big.NewInt(700), checks[0].RemainingBalance) + + exits := []*agglayertypes.BridgeExit{ + {TokenInfo: &agglayertypes.TokenInfo{OriginNetwork: 0, OriginTokenAddress: addr}, Amount: big.NewInt(600)}, + {TokenInfo: &agglayertypes.TokenInfo{OriginNetwork: 0, OriginTokenAddress: addr}, Amount: big.NewInt(400)}, + } + result := capCertificateExits(exits, checks) + require.Len(t, result, 2) + require.Equal(t, big.NewInt(600), result[0].Amount) + require.Equal(t, big.NewInt(100), result[1].Amount) // capped: 700-600=100 +} diff --git a/tools/exit_certificate/step_g1.go b/tools/exit_certificate/step_g1.go new file mode 100644 index 000000000..63a7cb592 --- /dev/null +++ b/tools/exit_certificate/step_g1.go @@ -0,0 +1,161 @@ +package exit_certificate + +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" + + "github.com/agglayer/aggkit/log" + "github.com/agglayer/aggkit/tools/exit_certificate/bridgesyncerlite" +) + +// liteDBSuffixes are the sqlite file plus its WAL/SHM sidecars, copied/removed together so the lite +// syncer DB is moved as a consistent unit. +var liteDBSuffixes = []string{"", "-wal", "-shm"} + +// g1LiteDBPath returns the lite syncer sqlite file Step G1 populates with the genesis→fork L2 +// bridges. It lives directly in the output dir (alongside the other step files). Step G2 copies it +// to g2LiteDBPath and works on that copy, leaving this one untouched. +func g1LiteDBPath(cfg *Config) string { + return filepath.Join(cfg.Options.OutputDir, fileStepG1LiteDB) +} + +// g2LiteDBPath returns the lite syncer sqlite file Step G2 works on: a copy of g1LiteDBPath onto +// which G2 appends the replayed bridges and builds the exit tree, so Step G1's DB stays intact and +// reusable across G2 re-runs. +func g2LiteDBPath(cfg *Config) string { + return filepath.Join(cfg.Options.OutputDir, fileStepGLiteDB) +} + +// RunStepG1 persists the L2 bridge history Step G2 needs and resolves the block Step G2 forks at. +// +// It syncs every L2 bridge from genesis up to targetBlock against the real L2 (cfg.L2RPCURL) with +// the lite bridge syncer, persisting them (no tree yet) so Step G2 can insert the replayed bridges +// on top and build the whole exit tree once. The full-history scan runs against the fast real L2 +// rather than the slow Anvil fork. The shadow-fork block is exactly the resolved targetBlock (the +// lite syncer fetches that range, no overshoot), so Anvil forks there aligned to the contract's +// state at that block. +func RunStepG1(ctx context.Context, cfg *Config, targetBlock uint64) (*StepG1Result, error) { + log.Info("═══════════════════════════════════════════") + log.Infof(" STEP G1 - sync l2 bridges till targetBlock = %d", targetBlock) + log.Info("═══════════════════════════════════════════") + + // Build the bridge history from genesis up to targetBlock with the lite bridge syncer, persisting + // it so Step G2 can append the replayed shadow-fork leaves on top. + if err := syncLiteToBlock(ctx, cfg, targetBlock); err != nil { + return nil, fmt.Errorf("lite-sync L2 bridges up to block %d: %w", targetBlock, err) + } + log.Infof("STEP G1 complete: L2 bridges lite-synced up to block %d (shadow-fork block); DB: %s", + targetBlock, g1LiteDBPath(cfg)) + return &StepG1Result{ShadowForkBlock: targetBlock}, nil +} + +// syncLiteToBlock persists all L2 bridges from genesis up to targetBlock with the lite bridge +// syncer, reading BridgeEvent logs from the real L2 (cfg.L2RPCURL) in parallel into the DB at +// g1LiteDBPath(cfg) (directly in the output dir). It does NOT build the exit tree — Step G2 builds +// it once, after appending the replayed shadow-fork bridges, so the tree is assembled a single time +// from the full set. Any pre-existing DB is deleted first so a re-run reflects the current chain +// state. It aborts (via the lite syncer) if the chain emitted any event that would invalidate a +// BridgeEvent-only reconstruction (token remappings, legacy migrations, LET rollbacks/advances). +func syncLiteToBlock(ctx context.Context, cfg *Config, targetBlock uint64) error { + dbPath := g1LiteDBPath(cfg) + // Delete any pre-existing lite syncer DB (and its WAL/SHM sidecars) so a re-run reflects the + // current chain state rather than resuming/duplicating a previous sync. The DB lives directly in + // the output dir, so only these files are removed — the other step files are left untouched. + if err := removeLiteDB(dbPath); err != nil { + return err + } + + syncer, err := bridgesyncerlite.New(ctx, bridgesyncerlite.Config{ + RPCURL: cfg.L2RPCURL, + BridgeAddr: cfg.L2BridgeAddress, + DBPath: dbPath, + BlockChunkSize: uint64(cfg.Options.BlockRange), + Concurrency: cfg.Options.ConcurrencyLimit, + IgnoreUnsupportedL2Events: cfg.Options.IgnoreUnsupportedL2Events, + }, log.WithFields("module", "exit-cert-bridgesyncerlite")) + if err != nil { + return err + } + defer func() { + if cerr := syncer.Close(); cerr != nil { + log.Warnf("error closing lite bridge syncer: %v", cerr) + } + }() + + log.Infof("Lite-syncing L2 bridges [0..%d] against the real L2 (%s)...", targetBlock, cfg.L2RPCURL) + if err := syncer.Sync(ctx, 0, targetBlock); err != nil { + return err + } + + bridgeCount, err := syncer.CountBridges(ctx) + if err != nil { + return err + } + log.Infof("Lite-synced %d L2 bridges up to block %d into %s (exit tree deferred to Step G2)", + bridgeCount, targetBlock, dbPath) + return nil +} + +// removeLiteDB deletes the lite syncer sqlite file and its WAL/SHM sidecars if present, logging when +// an existing DB is removed. Missing files are not an error. +func removeLiteDB(dbPath string) error { + if _, err := os.Stat(dbPath); err == nil { + log.Infof("Removing existing lite syncer DB %s", dbPath) + } + for _, suffix := range liteDBSuffixes { + p := dbPath + suffix + if err := os.Remove(p); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("remove lite syncer DB file %s: %w", p, err) + } + } + return nil +} + +// copyLiteDB copies the lite syncer sqlite file at srcPath (and its WAL/SHM sidecars, if present) to +// dstPath, replacing any existing destination first. Step G2 uses it to work on a copy of Step G1's +// DB, leaving the original intact. srcPath's main file must exist; absent sidecars are skipped. +func copyLiteDB(srcPath, dstPath string) error { + if err := removeLiteDB(dstPath); err != nil { + return err + } + for _, suffix := range liteDBSuffixes { + src := srcPath + suffix + if _, err := os.Stat(src); err != nil { + if os.IsNotExist(err) { + continue // sidecar may not exist (e.g. WAL checkpointed on close) + } + return fmt.Errorf("stat lite syncer DB file %s: %w", src, err) + } + if err := copyFile(src, dstPath+suffix); err != nil { + return fmt.Errorf("copy lite syncer DB file %s: %w", src, err) + } + } + return nil +} + +// copyFile copies the contents of src to dst (truncating dst), streaming so large DBs are not held +// in memory. +func copyFile(src, dst string) (err error) { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + out, err := os.Create(dst) + if err != nil { + return err + } + // Surface the close error (e.g. a deferred flush failing on a full disk) when the copy itself + // succeeded; if the copy already failed, that error takes precedence and the close error is + // dropped intentionally. + defer func() { + if cerr := out.Close(); cerr != nil && err == nil { + err = cerr + } + }() + _, err = io.Copy(out, in) + return err +} diff --git a/tools/exit_certificate/step_g1_test.go b/tools/exit_certificate/step_g1_test.go new file mode 100644 index 000000000..7ca6d18ba --- /dev/null +++ b/tools/exit_certificate/step_g1_test.go @@ -0,0 +1,119 @@ +package exit_certificate + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/stretchr/testify/require" +) + +// newEmptyLogsRPCServer returns a JSON-RPC server that answers every call with an empty array, so the +// lite syncer's eth_getLogs windows resolve to zero BridgeEvents and Sync completes without persisting +// anything. It handles both single and batched requests. +func newEmptyLogsRPCServer(t *testing.T) string { + t.Helper() + reply := func(id json.RawMessage) map[string]any { + return map[string]any{"jsonrpc": "2.0", "id": id, "result": []any{}} + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + w.Header().Set("Content-Type", "application/json") + trimmed := bytes.TrimSpace(body) + if len(trimmed) > 0 && trimmed[0] == '[' { + var reqs []struct { + ID json.RawMessage `json:"id"` + } + _ = json.Unmarshal(trimmed, &reqs) + resps := make([]map[string]any, len(reqs)) + for i, req := range reqs { + resps[i] = reply(req.ID) + } + _ = json.NewEncoder(w).Encode(resps) + return + } + var req struct { + ID json.RawMessage `json:"id"` + } + _ = json.Unmarshal(trimmed, &req) + _ = json.NewEncoder(w).Encode(reply(req.ID)) + })) + t.Cleanup(srv.Close) + return srv.URL +} + +// TestRunStepG1 drives the happy path against a fake L2 that emits no BridgeEvents: the step +// lite-syncs the [0..targetBlock] range, returns the target block as the shadow-fork block, and +// leaves the G1 lite DB on disk for Step G2. +func TestRunStepG1(t *testing.T) { + t.Parallel() + cfg := testConfig(t) + cfg.Options.BlockRange = 100 + cfg.Options.ConcurrencyLimit = 2 + cfg.L2RPCURL = newEmptyLogsRPCServer(t) + + const targetBlock = uint64(250) + res, err := RunStepG1(context.Background(), cfg, targetBlock) + require.NoError(t, err) + require.NotNil(t, res) + require.Equal(t, targetBlock, res.ShadowForkBlock) + require.FileExists(t, g1LiteDBPath(cfg)) + + // A pre-existing DB is wiped and re-synced on a second run, still resolving the same block. + res2, err := RunStepG1(context.Background(), cfg, targetBlock) + require.NoError(t, err) + require.Equal(t, targetBlock, res2.ShadowForkBlock) +} + +// TestRunStepG1SyncError covers the error path: an unreachable RPC makes the lite sync fail, and the +// failure is surfaced wrapped with the target block context. +func TestRunStepG1SyncError(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "boom", http.StatusInternalServerError) + })) + t.Cleanup(srv.Close) + + cfg := testConfig(t) + cfg.Options.BlockRange = 100 + cfg.Options.ConcurrencyLimit = 2 + cfg.L2RPCURL = srv.URL + + _, err := RunStepG1(context.Background(), cfg, 250) + require.Error(t, err) + require.Contains(t, err.Error(), "lite-sync L2 bridges up to block 250") +} + +// TestRunStepG1DialError covers the New/dial failure path: an unparsable RPC URL makes +// bridgesyncerlite.New fail before any sync, and RunStepG1 wraps the error. +func TestRunStepG1DialError(t *testing.T) { + t.Parallel() + cfg := testConfig(t) + cfg.Options.BlockRange = 100 + cfg.L2RPCURL = "://not-a-valid-url" + + _, err := RunStepG1(context.Background(), cfg, 100) + require.Error(t, err) + require.Contains(t, err.Error(), "lite-sync L2 bridges up to block 100") +} + +// TestSyncLiteToBlockRemovesStaleDB verifies syncLiteToBlock deletes a pre-existing lite DB (so a +// re-run reflects current chain state) before syncing afresh. +func TestSyncLiteToBlockRemovesStaleDB(t *testing.T) { + t.Parallel() + cfg := testConfig(t) + cfg.Options.BlockRange = 100 + cfg.L2RPCURL = newEmptyLogsRPCServer(t) + + // Drop a stale file where the lite DB will live; syncLiteToBlock must remove and replace it. + require.NoError(t, os.MkdirAll(cfg.Options.OutputDir, 0o755)) + require.NoError(t, os.WriteFile(g1LiteDBPath(cfg), []byte("stale"), 0o644)) + + require.NoError(t, syncLiteToBlock(context.Background(), cfg, 100)) + require.FileExists(t, g1LiteDBPath(cfg)) +} diff --git a/tools/exit_certificate/step_g2.go b/tools/exit_certificate/step_g2.go new file mode 100644 index 000000000..3b33f9730 --- /dev/null +++ b/tools/exit_certificate/step_g2.go @@ -0,0 +1,1703 @@ +package exit_certificate + +import ( + "bytes" + "context" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "math/big" + "net" + "os/exec" + "strconv" + "strings" + "sync" + "time" + + agglayerbridgel2 "github.com/0xPolygon/cdk-contracts-tooling/contracts/aggchain-multisig/agglayerbridgel2" + agglayertypes "github.com/agglayer/aggkit/agglayer/types" + bridgesynctypes "github.com/agglayer/aggkit/bridgesync/types" + "github.com/agglayer/aggkit/log" + "github.com/agglayer/aggkit/tools/exit_certificate/bridgesyncerlite" + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" +) + +const ( + anvilReadyTimeout = 30 * time.Second + anvilPollInterval = 300 * time.Millisecond + // receiptPollTimeout is how long a collector waits for one tx's receipt. With interval mining a + // tx's receipt only appears once its whole block is mined, which — for a block batching many + // cold-state txs against a remote fork — can take a while, so this is generous to avoid false + // timeouts. A tx that exceeds this bound is not aborted immediately: its exit is deferred and + // retried after the main send/collect phase (see retryDeferredExit), and only a retry failure is + // terminal. + receiptPollTimeout = 300 * time.Second + // receiptPollInterval is how long a worker waits between receipt polls. With --no-mining the tx + // is mined by the background miner (see backgroundMineInterval), not synchronously on send, so + // the first poll always misses; keep this small so that miss costs ~tens of ms, not a fixed 200ms + // floor per tx (which at mainnet scale dominated the whole replay). + receiptPollInterval = 25 * time.Millisecond + + // largeETHBalance is MaxUint256 in hex, enough for any bridgeAsset call regardless of exit amounts. + largeETHBalance = "0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + + abiFuncSelectorSize = 4 // bytes in an ABI function selector + + // uint256Bits is the bit width of an EVM uint256, used to build maxUint256 (2^256-1). + uint256Bits = 256 + + // replayProgressSteps is how many progress lines replayBridgeExits aims to emit over the full + // replay (one roughly every 1% of exits), instead of one line per individual bridge. + replayProgressSteps = 100 + + // replayLogMaxGap caps how long the replay can run without emitting a progress line, so there is + // periodic feedback even when 1% of exits (replayProgressSteps) takes a long time to complete. + replayLogMaxGap = 15 * time.Second + + // forkRetryAttempts/forkRetryBackoff bound how often a replay tx send is retried when the remote + // fork backend drops a request (see isTransientForkError). Forking a remote RPC under concurrency + // causes intermittent transport failures; a few backed-off retries ride them out without killing + // the whole replay. + forkRetryAttempts = 5 + forkRetryBackoff = 500 * time.Millisecond + + // replayInFlightWindow bounds how many sent-but-unconfirmed bridge txs sit in Anvil's mempool at + // once (the send/collect pipeline's channel capacity). It decouples send throughput from the + // per-tx receipt wait while keeping block size and memory bounded — sending all exits at once + // would have Anvil mine one gigantic block. It also caps how many txs land in a single interval + // block, bounding that block's mine time (and thus the receipt latency collectors wait on). + replayInFlightWindow = 2000 + + // anvilBlockTimeSeconds is Anvil's --block-time: it mines a block on this fixed interval, batching + // all txs pending at each tick into one block. This bounds block count (runtime/interval) instead + // of one-per-tx (~hundreds of thousands), which kept Anvil from degrading. A worker waits up to + // one interval for its receipt, so this also caps replay throughput at ~concurrency/interval. + anvilBlockTimeSeconds = 2 + + // anvilTxGasLimit is the explicit gas limit set on every replay transaction. We do NOT rely on + // Anvil's auto gas estimation: the parallel replay submits many bridgeAsset txs concurrently, so + // estimateGas runs against a pending state whose global depositCount (and thus the exit-tree + // Merkle path / SSTORE cost) differs from what the tx sees when actually mined. That under-estimate + // caused intermittent out-of-gas reverts ("reverted: no revert reason available"). A fixed, generous + // limit (well under Anvil's 30M block limit) removes the estimation race. A bridgeAsset costs ~300k. + anvilTxGasLimit = "0x4c4b40" // 5,000,000 +) + +var ( + // bridgeABI is the parsed ABI for the AgglayerBridgeL2 contract, used to + // encode/decode bridgeAsset, getRoot, and getTokenWrappedAddress calls. + bridgeABI abi.ABI + + bridgeEventTopicHash common.Hash + + // maxUint256 is 2^256-1, used as the patched ERC-20 balance and approve amount so a sender can + // bridge a token any number of times without underflowing its balance/allowance. + maxUint256 = new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), uint256Bits), big.NewInt(1)) + + // errReceiptTimeout marks a receipt poll that exhausted receiptPollTimeout without the tx mining, + // as opposed to a revert or a hard RPC error. Collectors defer these exits for a retry pass rather + // than aborting the replay (a revert is deterministic and still aborts immediately). + errReceiptTimeout = errors.New("timeout waiting for receipt") +) + +func init() { + parsed, err := agglayerbridgel2.Agglayerbridgel2MetaData.GetAbi() + if err != nil { + panic(fmt.Sprintf("parse agglayerbridgel2 ABI: %v", err)) + } + bridgeABI = *parsed + bridgeEventTopicHash = crypto.Keccak256Hash([]byte( + "BridgeEvent(uint8,uint32,address,uint32,address,uint256,bytes,uint32)", + )) +} + +// tokenOriginKey identifies an L1/L2 token by its origin chain and address. +type tokenOriginKey struct { + network uint32 + addr common.Address +} + +// rpcLog is the JSON representation of a log entry in an eth_getTransactionReceipt response. +type rpcLog struct { + Address string `json:"address"` + Topics []string `json:"topics"` + Data string `json:"data"` + BlockNumber string `json:"blockNumber"` + LogIndex string `json:"logIndex"` +} + +type bridgeEventLog struct { + LeafType uint8 + OriginNetwork uint32 + OriginAddress common.Address + DestinationNetwork uint32 + DestinationAddress common.Address + Amount *big.Int + Metadata []byte + DepositCount uint32 +} + +// FailedBridgeExit records the bridge exit whose replay aborted Step G, persisted to +// step-g-failed-exit.json so the offending exit can be inspected after the run fails. +type FailedBridgeExit struct { + Index int `json:"index"` + Error string `json:"error"` + OriginNetwork uint32 `json:"originNetwork"` + OriginTokenAddress string `json:"originTokenAddress"` + DestinationNetwork uint32 `json:"destinationNetwork"` + DestinationAddress string `json:"destinationAddress"` + Amount string `json:"amount"` + IsNative bool `json:"isNative"` + L2TokenAddress string `json:"l2TokenAddress"` +} + +// isContextCanceled reports whether err is (or wraps) context.Canceled. Used to suppress noisy +// error logs from the in-flight replay workers that abort once failFast cancels the shared context +// after the first real failure — those cancellations are expected, not the root cause. +func isContextCanceled(err error) bool { + return errors.Is(err, context.Canceled) +} + +// isTransientForkError reports whether err looks like a transient failure of Anvil's fork backend +// (the upstream L2 RPC) — a dropped connection, transport error, or timeout while Anvil lazily +// fetches forked state — rather than a real EVM revert. Forking a remote/public RPC under high +// concurrency triggers these intermittently; they are worth retrying, whereas a contract revert is +// deterministic and must not be retried. +func isTransientForkError(err error) bool { + if err == nil || isContextCanceled(err) { + return false + } + msg := strings.ToLower(err.Error()) + // A genuine revert is reported as "...reverted..."; never treat those as transient. + if strings.Contains(msg, "revert") { + return false + } + for _, marker := range []string{"fork error", "transport", "dispatch", "timeout", "connection", "eof"} { + if strings.Contains(msg, marker) { + return true + } + } + return false +} + +// RunStepG2 computes Certificate.NewLocalExitRoot and the per-exit metadata. +// +// The flow is the same in both modes: optionally run the shadow-fork, then build the local exit tree +// from the certificate, then compare the roots when both exist. +// +// - By default (options.verifyNewLocalExitRootUsingShadowFork is true — see defaultOptions) it spins +// up the Anvil shadow-fork, replays every exit against the real bridge contract, and takes the +// contract's getRoot() as the NewLocalExitRoot, having reordered the certificate to the on-chain +// deposit order and recovered the on-chain metadata. The off-chain tree built next must match it. +// - When the option is false it skips Anvil, leaves the certificate as-is, and takes the off-chain +// lite tree root as the NewLocalExitRoot (trusting the off-chain leaf encoding — nothing to verify +// against). +// +// forkBlock is the block resolved by Step G1. lbtEntries (Step 0 output) is used only by the +// shadow-fork path as a wrapped-token lookup so getTokenWrappedAddress RPC calls are avoided. +func RunStepG2( + ctx context.Context, cfg *Config, forkBlock uint64, certificate *agglayertypes.Certificate, lbtEntries []LBTEntry, +) (*StepGResult, error) { + return runStepG2(ctx, cfg, anvilLauncher{}, forkBlock, certificate, lbtEntries) +} + +// runStepG2 is the launcher-injectable orchestrator behind RunStepG2 (tests pass a mock fork +// launcher in place of anvilLauncher{}). See RunStepG2 for the flow. +func runStepG2( + ctx context.Context, cfg *Config, launcher forkLauncher, forkBlock uint64, + certificate *agglayertypes.Certificate, lbtEntries []LBTEntry, +) (*StepGResult, error) { + log.Info("═══════════════════════════════════════════") + log.Info(" STEP G2 - Calculate NewLocalExitRoot") + log.Info("═══════════════════════════════════════════") + + if certificate == nil { + return nil, fmt.Errorf("certificate is nil") + } + + if len(certificate.BridgeExits) == 0 { + log.Info("No bridge exits — using EmptyLER") + initialLER, err := readLocalExitRoot(ctx, cfg.L2RPCURL, cfg.L2BridgeAddress, toBlockTag(forkBlock)) + if err != nil { + log.Warnf("Could not read initial LocalExitRoot: %v", err) + } + log.Infof("InitialLocalExitRoot: %s", initialLER.Hex()) + return &StepGResult{ + InitialLocalExitRoot: initialLER, + NewLocalExitRoot: bridgesynctypes.EmptyLER, + BridgeExitCount: 0, + }, nil + } + + // Optionally run the shadow-fork. It replays every exit against the real bridge contract and + // returns the authoritative LER (the contract's getRoot()) and the LER at the fork block, having + // reordered certificate.BridgeExits to the on-chain deposit order. When disabled, the certificate + // is left as-is and there is no contract LER to compare to. + var ( + shadowForkLER *common.Hash + initialLERShadowFork common.Hash + err error + onChainMetadataShadowFork [][]byte + ) + // metadataBackend is the bridge contract endpoint generateMetadata queries getTokenMetadata against: + // the Anvil shadow-fork when it runs (already at forkBlock), otherwise a backend over the real L2. + var metadataBackend forkBackend + if cfg.Options.VerifyNewLocalExitRootUsingShadowFork { + backend, cleanup, startErr := launcher.Start(ctx, cfg.L2RPCURL, forkBlock, cfg.L2BridgeAddress) + if startErr != nil { + return nil, startErr + } + defer cleanup() + metadataBackend = backend + + var lerShadowFork common.Hash + lerShadowFork, initialLERShadowFork, onChainMetadataShadowFork, err = + runStepG2ShadowFork(ctx, cfg, backend, certificate, lbtEntries) + if err != nil { + return nil, err + } + shadowForkLER = &lerShadowFork + } else { + log.Info("Shadow-fork verification disabled; building the local exit tree off-chain only") + metadataBackend = &anvilForkBackend{url: cfg.L2RPCURL, bridgeAddr: cfg.L2BridgeAddress} + // InitialLocalExitRoot (the LER at the fork block) is informational here; read it from the real L2. + initialLERShadowFork, err = readLocalExitRoot(ctx, cfg.L2RPCURL, cfg.L2BridgeAddress, toBlockTag(forkBlock)) + if err != nil { + log.Warnf("Could not read initial LocalExitRoot: %v", err) + } + log.Infof("InitialLocalExitRoot: %s", initialLERShadowFork.Hex()) + } + // Reconstruct each exit's metadata the way the bridge contract does (and set it on the exits so the + // lite tree built below encodes the same leaves). When the shadow-fork ran, cross-check it against + // the on-chain metadata the replay recovered before relying on it. + log.Info("step G2: generating metadata for each bridge exit...") + generatedMetadata, err := generateMetadata(ctx, metadataBackend, cfg, certificate, lbtEntries) + if err != nil { + return nil, fmt.Errorf("generate metadata: %w", err) + } + if onChainMetadataShadowFork != nil { + log.Debug("step G2: comparing generated metadata with on-chain metadata...") + if err := compareMetadata(certificate, onChainMetadataShadowFork, generatedMetadata); err != nil { + log.Infof("❌ generated metadata mismath on-chain metadata recovered from the shadow-fork: err %w", err) + return nil, fmt.Errorf("compare metadata: %w", err) + } + log.Infof("✅ generated metadata matches on-chain metadata recovered from the shadow-fork replay for all %d exits") + } + // When the shadow-fork ran we have two on-chain anchors to verify the off-chain reconstruction + // against: the LER at the fork block (initialLER) and getRoot() after the replay (shadowForkLER). + // Build the genesis→fork lite tree (Step G1's bridges, no cert exits) first; its root must equal + // initialLER. This validates Step G1's bridge-history reconstruction on its own before the cert + // exits are added. + var genesisForkRoot common.Hash + if shadowForkLER != nil { + if genesisForkRoot, err = buildLiteTreeWithReplayed(ctx, cfg, nil); err != nil { + return nil, err + } + } + + // Always build the local exit tree from the (possibly reordered) certificate bridge exits. This is + // the sqlite the claimer later reads for its proofs, so it must be built last (overwriting the + // genesis→fork tree above with the full tree) and from the certificate exits themselves, using each + // exit's own metadata. + treeRoot, metadatas, err := runStepG2BuildLocalExitTree(ctx, cfg, forkBlock, certificate, generatedMetadata) + if err != nil { + return nil, err + } + + // The NewLocalExitRoot is the contract's getRoot() when the shadow-fork ran, otherwise the + // off-chain tree root. When the shadow-fork ran, the off-chain BridgeEvent-only reconstruction must + // match the real on-chain exit tree at both anchors — genesis→fork root vs initialLER and full tree + // root vs getRoot() — or the certificate would carry a wrong LER, so any divergence aborts. + newLER := treeRoot + if shadowForkLER != nil { + newLER = *shadowForkLER + if treeRoot != *shadowForkLER { + return nil, fmt.Errorf("lite exit tree root %s does not match contract getRoot %s: "+ + "the BridgeEvent-only reconstruction diverged from the on-chain exit tree", + treeRoot.Hex(), shadowForkLER.Hex()) + } + if genesisForkRoot != initialLERShadowFork { + return nil, fmt.Errorf("genesis→fork lite tree root %s does not match contract initial LER %s: "+ + "Step G1's bridge-history reconstruction diverged from the on-chain exit tree at the fork block", + genesisForkRoot.Hex(), initialLERShadowFork.Hex()) + } + log.Infof("✅ lite exit tree matches contract: initial LER %s, getRoot %s", + initialLERShadowFork.Hex(), shadowForkLER.Hex()) + } + + result := &StepGResult{ + InitialLocalExitRoot: initialLERShadowFork, + NewLocalExitRoot: newLER, + BridgeExitCount: uint64(len(certificate.BridgeExits)), + BridgeExitMetadata: metadatas, + } + log.Infof("Bridge exits processed: %d", result.BridgeExitCount) + log.Infof("NewLocalExitRoot: %s", result.NewLocalExitRoot.Hex()) + log.Info("STEP G complete") + return result, nil +} + +// generateMetadata reconstructs, for every bridge exit, the metadata the bridge contract embeds in +// the exit-tree leaf when bridgeAsset is called — replicating AgglayerBridge.bridgeAsset exactly: +// +// - native exit (gas token / zero token address): metadata is the contract's stored gasTokenMetadata. +// With no custom gas token (enforced by Step CHECK) the gas token is ETH and this is empty bytes. +// - ERC-20 exit (wrapped from another network OR L2-native): metadata is bridgeLib.getTokenMetadata +// of the L2 token — the ABI-encoded (name, symbol, decimals) of the token being bridged. +// +// (The contract's WETH branch — empty metadata — cannot occur here: it only applies when a custom gas +// token is set, which Step CHECK rejects, so WETHToken is the zero address and falls into the native +// branch above.) +// +// It queries the bridge contract through backend (the Anvil shadow-fork when it runs, otherwise a +// backend over the real L2), so it works in both Step G2 modes. Step D builds the L2 exits with empty +// metadata, so without this the off-chain lite tree would diverge from the real exit tree for any +// token whose leaf carries non-empty metadata. The returned (raw) metadata is what the lite tree is +// built from; the certificate's Metadata field is set to its keccak256 hash later, when the tree is +// built (see runStepG2BuildLocalExitTree). +// +// params: +// - backend: the bridge contract endpoint queried for getTokenMetadata / gasTokenMetadata +// - certificate: the certificate whose bridge exits' metadata is generated (in their current order) +// - lbtEntries: Step 0 output, used as a wrapped-token lookup so most getTokenWrappedAddress RPC +// calls are avoided +// +// returns: +// - [][]byte: the raw metadata generated for each bridge exit, aligned by index with certificate.BridgeExits +// - error: if any error occurs during the metadata generation process +func generateMetadata( + ctx context.Context, backend forkBackend, cfg *Config, + certificate *agglayertypes.Certificate, lbtEntries []LBTEntry, +) ([][]byte, error) { + exits := certificate.BridgeExits + metadatas := make([][]byte, len(exits)) + if len(exits) == 0 { + return metadatas, nil + } + + gasTokenNetwork, gasTokenAddress := fetchGasTokenInfoOrDefault(ctx, cfg) + + // Resolve each ERC-20 exit's L2 token address; the bridge's getTokenMetadata is keyed by that + // address, exactly as bridgeAsset(token) is. + l2Tokens, err := resolveTokenAddresses( + ctx, backend, exits, cfg.L2NetworkID, gasTokenNetwork, gasTokenAddress, buildLBTTokenMap(lbtEntries), + ) + if err != nil { + return nil, fmt.Errorf("resolve token addresses: %w", err) + } + + // Metadata is identical for every exit of the same L2 token, so resolve each token once. + tokenMetaCache := make(map[common.Address][]byte) + var ( + gasTokenMeta []byte + gasTokenMetaFetched bool + ) + for i, be := range exits { + if isNativeBridgeExit(be.TokenInfo, gasTokenNetwork, gasTokenAddress) { + if !gasTokenMetaFetched { + if gasTokenMeta, err = backend.GasTokenMetadata(ctx); err != nil { + return nil, fmt.Errorf("get gas token metadata: %w", err) + } + gasTokenMetaFetched = true + } + metadatas[i] = gasTokenMeta + continue + } + l2TokenAddr, err := findTokenAddress(be, l2Tokens) + if err != nil { + return nil, fmt.Errorf("find token address: %w", err) + } + md, ok := tokenMetaCache[l2TokenAddr] + if !ok { + if md, err = backend.TokenMetadata(ctx, l2TokenAddr); err != nil { + return nil, fmt.Errorf("get token metadata for L2 token %s: %w", l2TokenAddr.Hex(), err) + } + tokenMetaCache[l2TokenAddr] = md + } + metadatas[i] = md + } + return metadatas, nil +} + +// compareMetadata checks the generated metadata matches the shadow-fork metadata when the shadow-fork +// ran (shadowForkMetadata is nil in off-chain mode, where there is nothing on-chain to compare to). A +// mismatch means generateMetadata's contract replica diverged from the real on-chain metadata the +// replay recovered, which would diverge the lite tree from the contract's getRoot(); failing here +// names the offending exit instead of surfacing only as an opaque root mismatch. +func compareMetadata( + certificate *agglayertypes.Certificate, shadowForkMetadata [][]byte, generatedMetadata [][]byte, +) error { + if shadowForkMetadata == nil { + return nil + } + exits := certificate.BridgeExits + if len(shadowForkMetadata) != len(exits) || len(generatedMetadata) != len(exits) { + return fmt.Errorf("metadata length mismatch: exits=%d generated=%d shadow-fork=%d", + len(exits), len(generatedMetadata), len(shadowForkMetadata)) + } + for i := range exits { + if !bytes.Equal(generatedMetadata[i], shadowForkMetadata[i]) { + return fmt.Errorf( + "bridge exit %d (dest %s): generated metadata 0x%x does not match on-chain metadata 0x%x "+ + "recovered from the shadow-fork replay", + i, exits[i].DestinationAddress.Hex(), generatedMetadata[i], shadowForkMetadata[i]) + } + } + return nil +} + +// runStepG2ShadowFork replays every bridge exit against the shadow-fork backend (an Anvil fork of the +// L2 chain at the Step G1 block) and returns the authoritative NewLocalExitRoot (the contract's +// getRoot() after the replay) and the initial LER at the fork block. As a side effect it reorders +// certificate.BridgeExits to the on-chain deposit order so callers build the local exit tree in the +// same order agglayer rebuilds the LER from. It does not build the tree or verify the root — that is +// the orchestrator's job (RunStepG2). The backend's lifecycle is owned by the caller. +func runStepG2ShadowFork( + ctx context.Context, cfg *Config, backend forkBackend, + certificate *agglayertypes.Certificate, lbtEntries []LBTEntry, +) (common.Hash, common.Hash, [][]byte, error) { + gasTokenNetwork, gasTokenAddress := fetchGasTokenInfoOrDefault(ctx, cfg) + + initialLER, err := backend.LocalExitRoot(ctx, "latest") + if err != nil { + return common.Hash{}, common.Hash{}, nil, fmt.Errorf("read initial local exit root: %w", err) + } + log.Infof("InitialLocalExitRoot: %s", initialLER.Hex()) + + lbtMap := buildLBTTokenMap(lbtEntries) + l2Tokens, err := resolveTokenAddresses( + ctx, backend, certificate.BridgeExits, + cfg.L2NetworkID, gasTokenNetwork, gasTokenAddress, lbtMap, + ) + if err != nil { + return common.Hash{}, common.Hash{}, nil, fmt.Errorf("resolve token addresses: %w", err) + } + for k, v := range l2Tokens { + log.Debugf("token map: origin(network=%d addr=%s) -> L2 wrapped %s", k.network, k.addr.Hex(), v.Hex()) + } + log.Infof("Replaying %d bridge exits on Anvil with concurrency %d...", + len(certificate.BridgeExits), max(cfg.Options.ConcurrencyLimit, 1)) + // Anvil mines on its own --block-time interval (see anvilBlockTimeSeconds); workers just send and + // poll for receipts. By the time replayBridgeExits returns, every tx has been waited on and mined, + // so getRoot below reflects all replayed exits. + leaves, err := replayBridgeExits( + ctx, cfg, backend, certificate.BridgeExits, l2Tokens, gasTokenNetwork, gasTokenAddress, + ) + if err != nil { + return common.Hash{}, common.Hash{}, nil, err + } + + // The bridge contract's getRoot() after replaying every exit is the authoritative NewLocalExitRoot. + ler, err := backend.LocalExitRoot(ctx, "latest") + if err != nil { + return common.Hash{}, common.Hash{}, nil, fmt.Errorf("read local exit root: %w", err) + } + + // Reorder the certificate to the canonical exit-tree order. The parallel replay assigned + // depositCounts non-deterministically across exits; each replayed BridgeEvent carries the + // depositCount the contract gave it, so sorting the exits by it aligns Certificate.BridgeExits with + // the leaf order agglayer rebuilds the LER from. The returned metadata is the replay's on-chain + // metadata aligned to the reordered exits. + onChainMetadata, err := reorderCertificateByDepositCount(certificate, leaves) + if err != nil { + return common.Hash{}, common.Hash{}, nil, fmt.Errorf("reorder certificate by deposit order: %w", err) + } + log.Infof("Reordered %d bridge exits to match the replay deposit order", len(certificate.BridgeExits)) + + return ler, initialLER, onChainMetadata, nil +} + +// verifyReplayMetadata checks that each bridge exit's own metadata equals the on-chain metadata the +// replay recovered for it (aligned by index after reordering). The local exit tree is built from the +// exits' own metadata, so a mismatch means the certificate carries wrong metadata for that exit and +// the off-chain tree would diverge from the contract's getRoot(); failing here names the offending +// exit instead of surfacing only as an opaque root mismatch. +func verifyReplayMetadata(exits []*agglayertypes.BridgeExit, onChainMetadata [][]byte) error { + for i, be := range exits { + if !bytes.Equal(be.Metadata, onChainMetadata[i]) { + return fmt.Errorf( + "bridge exit %d (dest %s): certificate metadata 0x%x does not match on-chain metadata 0x%x "+ + "recovered from the replay", + i, be.DestinationAddress.Hex(), be.Metadata, onChainMetadata[i]) + } + } + return nil +} + +// runStepG2BuildLocalExitTree builds the local exit tree from the certificate's bridge exits (in +// their current order) on top of Step G1's genesis→fork bridges, using the raw generatedMetadata for +// each leaf, and returns the tree root and that per-exit raw metadata. It produces the sqlite the +// claimer later reads for its proofs. +// +// As a side effect it updates each exit's Metadata field to the keccak256 hash of its raw metadata. +// The leaf encoding hashes the raw metadata (mirroring the contract's keccak256(metadata)), but +// agglayer's BridgeExit.Hash() plugs the Metadata field straight into the leaf hash — so the +// certificate must carry that hash, not the raw bytes, for its recomputed LER to match +// NewLocalExitRoot. This matches what Step I does when applying StepGResult.BridgeExitMetadata. +func runStepG2BuildLocalExitTree( + ctx context.Context, cfg *Config, forkBlock uint64, + certificate *agglayertypes.Certificate, generatedMetadata [][]byte, +) (common.Hash, [][]byte, error) { + gasTokenNetwork, gasTokenAddress := fetchGasTokenInfoOrDefault(ctx, cfg) + root, metadatas, err := buildLiteTreeFromCertificate( + ctx, cfg, certificate, forkBlock, gasTokenNetwork, gasTokenAddress, generatedMetadata, + ) + if err != nil { + return common.Hash{}, nil, err + } + // Store the metadata hash on the certificate (raw metadata stays in metadatas for StepGResult). + for i, be := range certificate.BridgeExits { + be.Metadata = crypto.Keccak256(generatedMetadata[i]) + } + return root, metadatas, nil +} + +// fetchGasTokenInfoOrDefault returns the L2 gas token (network, address), falling back to standard +// ETH (network 0, zero address) with a warning if the lookup fails. +func fetchGasTokenInfoOrDefault(ctx context.Context, cfg *Config) (uint32, common.Address) { + gasTokenNetwork, gasTokenAddress, err := fetchGasTokenInfo(ctx, cfg.L2RPCURL, cfg.L2BridgeAddress) + if err != nil { + log.Warnf("Failed to fetch gas token info (assuming standard ETH): %v", err) + return 0, common.Address{} + } + return gasTokenNetwork, gasTokenAddress +} + +// forkBackend abstracts every side-effecting interaction Step G2's shadow-fork replay has with the +// Anvil fork: reading contract state, patching balances/allowances, sending bridgeAsset txs and +// polling their receipts. The production implementation (anvilForkBackend) talks JSON-RPC to the +// Anvil process; tests substitute a mock to drive the replay orchestration (replayBridgeExits, +// retryDeferredExit, resolveTokenAddresses, runStepG2ShadowFork) without Anvil or a live node. +type forkBackend interface { + // LocalExitRoot returns the bridge contract's getRoot() at blockTag. + LocalExitRoot(ctx context.Context, blockTag string) (common.Hash, error) + // TokenWrappedAddress resolves an origin token to its L2 wrapped ERC-20 address via the bridge. + TokenWrappedAddress(ctx context.Context, originNetwork uint32, originTokenAddr common.Address) (common.Address, error) + // TokenMetadata returns the metadata blob the bridge embeds in a leaf for l2Token (ABI-encoded + // name/symbol/decimals), via getTokenMetadata. + TokenMetadata(ctx context.Context, l2Token common.Address) ([]byte, error) + // GasTokenMetadata returns the metadata blob the bridge embeds in a native (gas token) leaf, via + // gasTokenMetadata (empty when the gas token is ETH). + GasTokenMetadata(ctx context.Context) ([]byte, error) + // SetSenderBalance funds a sender so its bridgeAsset calls never fail on insufficient funds. + SetSenderBalance(ctx context.Context, sender common.Address) error + // PrepareERC20Token patches a large balance for sender and approves the bridge for l2TokenAddr. + PrepareERC20Token(ctx context.Context, sender, l2TokenAddr common.Address) error + // SendBridgeAssetTx sends a bridgeAsset replaying bridgeExit, returning the tx hash (no wait). + SendBridgeAssetTx( + ctx context.Context, bridgeExit *agglayertypes.BridgeExit, isNative bool, l2TokenAddr common.Address, + ) (common.Hash, error) + // WaitForReceipt polls for txHash's receipt, returning its logs or errReceiptTimeout/revert error. + WaitForReceipt(ctx context.Context, txHash common.Hash) ([]rpcLog, error) +} + +// anvilForkBackend is the production forkBackend: it issues JSON-RPC calls to the Anvil fork at url, +// delegating to the package's RPC helper functions. bridgeAddr is the L2 bridge contract address, +// constant for the whole replay. +type anvilForkBackend struct { + url string + bridgeAddr common.Address +} + +func (b *anvilForkBackend) LocalExitRoot(ctx context.Context, blockTag string) (common.Hash, error) { + return readLocalExitRoot(ctx, b.url, b.bridgeAddr, blockTag) +} + +func (b *anvilForkBackend) TokenWrappedAddress( + ctx context.Context, originNetwork uint32, originTokenAddr common.Address, +) (common.Address, error) { + return callGetTokenWrappedAddress(ctx, b.url, b.bridgeAddr, originNetwork, originTokenAddr) +} + +func (b *anvilForkBackend) TokenMetadata(ctx context.Context, l2Token common.Address) ([]byte, error) { + return callGetTokenMetadata(ctx, b.url, b.bridgeAddr, l2Token) +} + +func (b *anvilForkBackend) GasTokenMetadata(ctx context.Context) ([]byte, error) { + return callGasTokenMetadata(ctx, b.url, b.bridgeAddr) +} + +func (b *anvilForkBackend) SetSenderBalance(ctx context.Context, sender common.Address) error { + return setSenderBalance(ctx, b.url, sender) +} + +func (b *anvilForkBackend) PrepareERC20Token(ctx context.Context, sender, l2TokenAddr common.Address) error { + return prepareERC20Token(ctx, b.url, b.bridgeAddr, sender, l2TokenAddr) +} + +func (b *anvilForkBackend) SendBridgeAssetTx( + ctx context.Context, bridgeExit *agglayertypes.BridgeExit, isNative bool, l2TokenAddr common.Address, +) (common.Hash, error) { + return sendBridgeAssetTx(ctx, b.url, b.bridgeAddr, bridgeExit, isNative, l2TokenAddr) +} + +func (b *anvilForkBackend) WaitForReceipt(ctx context.Context, txHash common.Hash) ([]rpcLog, error) { + return waitForReceipt(ctx, b.url, txHash) +} + +// forkLauncher starts a fork backend Step G2 replays against. The production implementation +// (anvilLauncher) verifies Anvil is installed and spawns the forked Anvil process; tests use a mock +// that returns a mock forkBackend without launching anything. +type forkLauncher interface { + // Start brings up a fork of l2RPCURL at forkBlock and returns a backend bound to bridgeAddr plus a + // cleanup function the caller must defer. + Start( + ctx context.Context, l2RPCURL string, forkBlock uint64, bridgeAddr common.Address, + ) (forkBackend, func(), error) +} + +// anvilLauncher is the production forkLauncher: it checks for the anvil binary and spawns the fork. +type anvilLauncher struct{} + +func (anvilLauncher) Start( + ctx context.Context, l2RPCURL string, forkBlock uint64, bridgeAddr common.Address, +) (forkBackend, func(), error) { + if err := checkAnvilAvailable(); err != nil { + return nil, nil, err + } + anvilURL, cleanup, err := startAnvil(ctx, l2RPCURL, forkBlock) + if err != nil { + return nil, nil, fmt.Errorf("start anvil: %w", err) + } + return &anvilForkBackend{url: anvilURL, bridgeAddr: bridgeAddr}, cleanup, nil +} + +// exitJob bundles a bridge exit with its index in Certificate.BridgeExits and the +// replay parameters resolved up front (native flag and L2 token address). +type exitJob struct { + index int + bridge *agglayertypes.BridgeExit + isNative bool + l2TokenAddr common.Address +} + +// sentTx pairs a sent bridgeAsset transaction with the exit that produced it, so the collect phase +// can fetch the receipt, detect reverts, and record the BridgeEvent metadata at the right index. +type sentTx struct { + index int + hash common.Hash + job exitJob +} + +// replayBridgeExits replays every bridge exit against the Anvil shadow-fork and returns the +// BridgeEvent metadata indexed by the original position in exits. +// +// It uses a send/collect pipeline rather than send-and-wait per tx: with Anvil on a --block-time +// interval, waiting for each tx's receipt before sending the next would cap throughput at +// ~concurrency/block-time. Instead, sender workers fire all of a sender's txs without waiting +// (pushing each onto a bounded channel), while collector workers pull those and fetch receipts in +// parallel. The channel's capacity (replayInFlightWindow) bounds how many txs sit unconfirmed in +// Anvil's mempool, so block size and memory stay bounded (sending all ~915k at once would mine one +// gigantic block). Each metadata is written to metadatas[index], keeping it aligned with +// Certificate.BridgeExits regardless of completion order; the canonical deposit order is recovered +// later from the emitted BridgeEvents. +// +// Within a sender's group txs are sent sequentially so Anvil assigns nonces in order (an ERC-20 +// approve must precede its bridgeAsset). Balances/allowances are set generously once per sender (and +// per token) up front, so multiple exits from the same sender never underflow regardless of the +// order in which the batched block executes them. +func replayBridgeExits( + ctx context.Context, cfg *Config, backend forkBackend, + exits []*agglayertypes.BridgeExit, l2Tokens map[tokenOriginKey]common.Address, + gasTokenNetwork uint32, gasTokenAddress common.Address, +) ([]bridgesyncerlite.BridgeLeaf, error) { + // leaves[i] holds the full BridgeEvent (leaf content + depositCount + block position) emitted by + // the replay of exits[i]. The depositCount gives the canonical exit-tree order (used to reorder + // the certificate), and the leaf is inserted into the lite DB directly — no second pass over the + // fork is needed to recover either. + leaves := make([]bridgesyncerlite.BridgeLeaf, len(exits)) + + groupsBySender := make(map[common.Address][]exitJob) + for i, bridge := range exits { + isNative := isNativeBridgeExit(bridge.TokenInfo, gasTokenNetwork, gasTokenAddress) + var l2TokenAddr common.Address + if !isNative { + addr, err := findTokenAddress(bridge, l2Tokens) + if err != nil { + return nil, fmt.Errorf("find token address: %w", err) + } + l2TokenAddr = addr + } + sender := bridge.DestinationAddress + groupsBySender[sender] = append(groupsBySender[sender], exitJob{ + index: i, bridge: bridge, isNative: isNative, l2TokenAddr: l2TokenAddr, + }) + } + + groups := make([][]exitJob, 0, len(groupsBySender)) + for _, g := range groupsBySender { + groups = append(groups, g) + } + + concurrency := max(cfg.Options.ConcurrencyLimit, 1) + + // Fail fast: cancel the shared context on the first error so senders and collectors stop, and + // keep the real error in replayErr (the pipeline would otherwise surface context.Canceled). + ctx, cancel := context.WithCancel(ctx) + defer cancel() + var ( + replayErr error + replayOnce sync.Once + ) + failFast := func(job exitJob, err error) error { + replayOnce.Do(func() { + replayErr = err + // Persist the offending exit so it can be inspected after the run aborts. + saveFailedExit(cfg.Options.OutputDir, job, err) + cancel() + }) + return err + } + + total := len(exits) + // Progress is reported as an aggregate %/ETA over collected receipts (~100 log lines) rather than + // one line per bridge. A line is emitted on the first receipt, every logInterval, the last, and at + // least every replayLogMaxGap, so there is always early and periodic feedback. + start := time.Now() + logInterval := max(total/replayProgressSteps, 1) + // maybeLogProgress is called once per collected receipt from multiple goroutines. A single mutex + // guards the counter and the last-log timestamp together, so the decision and the timestamp update + // are atomic as a unit — no interleaving can emit a duplicate line. The lock is uncontended in + // practice (the work between calls is a receipt fetch), so it is not on a hot path. + var ( + progressMu sync.Mutex + completed int + lastLog time.Time + ) + maybeLogProgress := func() { + progressMu.Lock() + defer progressMu.Unlock() + completed++ + now := time.Now() + // Log on the first receipt, every logInterval, the last receipt, or after replayLogMaxGap + // elapsed without a line — whichever comes first. + if completed == 1 || completed%logInterval == 0 || completed == total || + now.Sub(lastLog) >= replayLogMaxGap { + lastLog = now + logReplayProgress(completed, total, start) + } + } + + // pending carries sent txs from the sender workers to the collector workers; its capacity bounds + // the number of unconfirmed txs in Anvil's mempool. + pending := make(chan sentTx, replayInFlightWindow) + + // deferred collects exits whose receipt timed out in the main phase (Anvil could not mine their + // block within receiptPollTimeout, typically a slow remote fork backend under load). Rather than + // abort, they are retried after the send/collect phase drains (see retryDeferredExit). + var ( + deferred []sentTx + deferredMu sync.Mutex + ) + + // Collectors: fetch each sent tx's receipt, detect reverts, and record its BridgeEvent metadata. + var collectWg sync.WaitGroup + for c := 0; c < concurrency; c++ { + collectWg.Add(1) + go func() { + defer collectWg.Done() + for s := range pending { + logs, err := backend.WaitForReceipt(ctx, s.hash) + if err != nil { + switch { + case isContextCanceled(err): + // Replay already aborting; stop quietly. + case errors.Is(err, errReceiptTimeout): + // Block did not mine in time; defer for the retry pass instead of aborting. + deferredMu.Lock() + deferred = append(deferred, s) + deferredMu.Unlock() + default: + _ = failFast(s.job, fmt.Errorf("get receipt %s for exit %d: %w", s.hash.Hex(), s.index+1, err)) + } + continue + } + leaf, err := replayedLeafFromReceipt(logs, s.hash) + if err != nil { + _ = failFast(s.job, fmt.Errorf("parse BridgeEvent for exit %d (%s): %w", s.index+1, s.hash.Hex(), err)) + continue + } + leaves[s.index] = leaf + maybeLogProgress() + } + }() + } + + // Senders: for each sender, fund it and pre-approve its tokens once, then send all its bridge + // txs (sequential for nonce order) onto pending without waiting for receipts. + sendGroup := func(group []exitJob) (struct{}, error) { + if len(group) == 0 { + return struct{}{}, nil + } + sender := group[0].bridge.DestinationAddress + if err := backend.SetSenderBalance(ctx, sender); err != nil { + return struct{}{}, failFast(group[0], fmt.Errorf("set balance for %s: %w", sender.Hex(), err)) + } + approved := make(map[common.Address]bool) + for _, job := range group { + if job.isNative || approved[job.l2TokenAddr] { + continue + } + approved[job.l2TokenAddr] = true + if err := backend.PrepareERC20Token(ctx, sender, job.l2TokenAddr); err != nil { + return struct{}{}, failFast(job, fmt.Errorf("prepare ERC20 token %s: %w", job.l2TokenAddr.Hex(), err)) + } + } + for _, job := range group { + log.Debugf("[exit %d/%d] send bridgeAsset [%d/%s] -> %s amount=%s isNative=%t", + job.index+1, total, job.bridge.TokenInfo.OriginNetwork, job.bridge.TokenInfo.OriginTokenAddress.Hex(), + job.bridge.DestinationAddress.Hex(), job.bridge.Amount.String(), job.isNative) + hash, err := backend.SendBridgeAssetTx(ctx, job.bridge, job.isNative, job.l2TokenAddr) + if err != nil { + return struct{}{}, failFast(job, fmt.Errorf("send bridge asset for exit %d: %w", job.index+1, err)) + } + select { + case pending <- sentTx{index: job.index, hash: hash, job: job}: + case <-ctx.Done(): + return struct{}{}, ctx.Err() + } + } + return struct{}{}, nil + } + + log.Infof("Sending bridge exits (in-flight window %d) and collecting receipts...", replayInFlightWindow) + sendErr := runWorkerPool(ctx, groups, concurrency, sendGroup, func(struct{}) { + // No-op collector: sendGroup forwards each sent tx to the `pending` channel itself, so the + // worker pool's struct{} result carries nothing to collect here. + }, "") + // All sends finished (or aborted): close pending so collectors drain and exit. + close(pending) + collectWg.Wait() + + // Retry exits whose receipt timed out. By now all sends are done and Anvil is draining its + // backlog, so the original blocks have likely mined; recovering them here keeps every exit's leaf + // at its original index without re-sending a tx that actually mined (see retryDeferredExit). + if replayErr == nil && sendErr == nil && len(deferred) > 0 { + log.Warnf("Retrying %d bridge exit(s) whose receipt timed out...", len(deferred)) + for _, s := range deferred { + leaf, err := retryDeferredExit(ctx, backend, s) + if err != nil { + _ = failFast(s.job, fmt.Errorf("retry exit %d (%s): %w", s.index+1, s.hash.Hex(), err)) + break + } + leaves[s.index] = leaf + maybeLogProgress() + } + } + + if replayErr != nil { + log.Errorf("Replay failed: %v", replayErr) + return nil, replayErr + } + if sendErr != nil { + log.Errorf("send phase failed: %v", sendErr) + return nil, sendErr + } + + return leaves, nil +} + +// retryDeferredExit recovers one exit whose receipt timed out during the main send/collect phase. +// +// It runs after all sends are done and Anvil is idle, and retries **unbounded** until the exit mines: +// each iteration re-polls the current tx (under a slow remote fork backend a block can take longer +// than receiptPollTimeout to mine, so the receipt has very likely appeared by now — waitForReceipt +// returns as soon as it does, accounting for the block interval). Only if the receipt is *still* +// absent after that full poll window — meaning the tx never landed in Anvil's mempool — is the +// bridgeAsset re-sent, and the next iteration polls the new hash. A slow backend is therefore never +// abandoned; the only exits are success, a revert, or context cancellation. +// +// This re-poll-before-resend ordering is what keeps the exit tree correct: a bridgeAsset that did +// mine adds a leaf and bumps depositCount, so re-sending one that already mined would double-count +// the exit and diverge the reconstructed tree from the contract's getRoot(). A revert (or any +// non-timeout error, including context cancellation) is returned as-is and is terminal — re-sending +// a reverting tx would not help, and a canceled context must break the loop. +func retryDeferredExit( + ctx context.Context, backend forkBackend, s sentTx, +) (bridgesyncerlite.BridgeLeaf, error) { + hash := s.hash + for attempt := 1; ; attempt++ { + logs, err := backend.WaitForReceipt(ctx, hash) + if err == nil { + return replayedLeafFromReceipt(logs, hash) + } + if !errors.Is(err, errReceiptTimeout) { + return bridgesyncerlite.BridgeLeaf{}, err + } + + log.Warnf("exit %d (%s) still has no receipt after attempt %d; re-sending bridgeAsset", + s.index+1, hash.Hex(), attempt) + newHash, err := backend.SendBridgeAssetTx(ctx, s.job.bridge, s.job.isNative, s.job.l2TokenAddr) + if err != nil { + return bridgesyncerlite.BridgeLeaf{}, fmt.Errorf("re-send bridge asset: %w", err) + } + hash = newHash + } +} + +// saveFailedExit writes the bridge exit whose replay aborted Step G to step-g-failed-exit.json in +// dir, so the offending exit can be inspected after the run fails. Best-effort: any write error is +// logged by saveJSON and does not mask the original replay error. +func saveFailedExit(dir string, job exitJob, replayErr error) { + fe := FailedBridgeExit{ + Index: job.index, + Error: replayErr.Error(), + DestinationNetwork: job.bridge.DestinationNetwork, + DestinationAddress: job.bridge.DestinationAddress.Hex(), + Amount: bigIntKey(job.bridge.Amount), + IsNative: job.isNative, + L2TokenAddress: job.l2TokenAddr.Hex(), + } + if job.bridge.TokenInfo != nil { + fe.OriginNetwork = job.bridge.TokenInfo.OriginNetwork + fe.OriginTokenAddress = job.bridge.TokenInfo.OriginTokenAddress.Hex() + } + saveJSON(dir, fileStepGFailedExit, fe) +} + +// logReplayProgress logs the replay completion percentage, throughput, and ETA. start is the +// time the replay began; done is the number of exits replayed so far out of total. +func logReplayProgress(done, total int, start time.Time) { + elapsed := time.Since(start) + rate := float64(done) / elapsed.Seconds() + eta := "—" + if rate > 0 { + remaining := total - done + eta = (time.Duration(float64(remaining)/rate) * time.Second).Round(time.Second).String() + } + log.Infof(" bridgeAsset replay: %d/%d (%.1f%%) — %.0f exits/s — ETA %s", + done, total, float64(done)/float64(total)*percentMultiplier, rate, eta) +} + +func isNativeBridgeExit( + ti *agglayertypes.TokenInfo, gasTokenNetwork uint32, gasTokenAddress common.Address, +) bool { + return ti == nil || + ti.OriginTokenAddress == (common.Address{}) || + (ti.OriginNetwork == gasTokenNetwork && ti.OriginTokenAddress == gasTokenAddress) +} + +// findTokenAddress looks up the L2 ERC-20 address for a bridge exit in the token map +// returned by resolveTokenAddresses. +func findTokenAddress( + bridgeExit *agglayertypes.BridgeExit, tokenMap map[tokenOriginKey]common.Address, +) (common.Address, error) { + if bridgeExit.TokenInfo == nil { + return common.Address{}, fmt.Errorf("bridge exit has nil TokenInfo") + } + ti := bridgeExit.TokenInfo + addr, ok := tokenMap[tokenOriginKey{ti.OriginNetwork, ti.OriginTokenAddress}] + if !ok { + return common.Address{}, fmt.Errorf("token (network=%d addr=%s) not found in token map", + ti.OriginNetwork, ti.OriginTokenAddress.Hex()) + } + return addr, nil +} + +// prepareERC20Token makes sender able to bridge the L2 ERC-20 token any number of times: it patches +// a large balance via Anvil storage manipulation and sends a single approve(bridge, MaxUint256). It +// does NOT wait for the approve receipt — the approve has a lower nonce than the sender's bridge txs, +// so Anvil executes it first when the batched block is mined; an insufficient-allowance failure would +// surface as a revert on the bridge tx's receipt. Called once per (sender, token). +func prepareERC20Token(ctx context.Context, rpcURL string, bridgeAddr, sender, l2TokenAddr common.Address) error { + if l2TokenAddr == (common.Address{}) { + return fmt.Errorf("invalid L2 token address") + } + log.Debugf("Preparing ERC-20 L2 token %s for sender %s (balance + approve MaxUint256)", + l2TokenAddr.Hex(), sender.Hex()) + + // A large balance covers every exit of this token for this sender regardless of how many there + // are or the order the batched block executes them; the per-exit burn amount is what affects the + // token's totalSupply, not this balance. + if err := ensureERC20Balance(ctx, rpcURL, l2TokenAddr, sender, maxUint256); err != nil { + return fmt.Errorf("ensure ERC-20 balance: %w", err) + } + + callData := encodeERC20ApproveCallRaw(bridgeAddr, maxUint256) + if _, err := sendAnvilTransaction(ctx, rpcURL, sender, l2TokenAddr, nil, callData); err != nil { + if !isContextCanceled(err) { + log.Errorf("Failed to send approve for ERC-20 token %s: %v", l2TokenAddr.Hex(), err) + } + return fmt.Errorf("send approve ERC-20 token %s: %w", l2TokenAddr.Hex(), err) + } + return nil +} + +// sendBridgeAssetTx sends (without waiting for the receipt) a bridgeAsset call replaying bridgeExit +// against the fork, returning the tx hash for the collect phase to fetch the receipt and metadata. +func sendBridgeAssetTx(ctx context.Context, rpcURL string, + bridgeAddr common.Address, + bridgeExit *agglayertypes.BridgeExit, + isNative bool, + l2TokenAddr common.Address) (common.Hash, error) { + sender := bridgeExit.DestinationAddress + + var value *big.Int + if isNative && bridgeExit.Amount != nil { + value = bridgeExit.Amount + } + + callData := encodeBridgeAssetCallRaw( + bridgeExit.DestinationNetwork, + bridgeExit.DestinationAddress, + bridgeExit.Amount, + l2TokenAddr, + ) + + txHash, err := sendAnvilTransaction(ctx, rpcURL, sender, bridgeAddr, value, callData) + if err != nil { + if !isContextCanceled(err) { + log.Errorf("Failed to send bridge asset tx: %v", err) + } + return common.Hash{}, fmt.Errorf("send bridge asset tx: %w", err) + } + return txHash, nil +} + +func checkAnvilAvailable() error { + if _, err := exec.LookPath("anvil"); err != nil { + return fmt.Errorf("anvil not found in $PATH — install the Foundry toolchain from https://getfoundry.sh") + } + return nil +} + +func findFreePort() (int, error) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return 0, err + } + defer ln.Close() + tcpAddr, ok := ln.Addr().(*net.TCPAddr) + if !ok { + return 0, fmt.Errorf("unexpected listener address type %T", ln.Addr()) + } + return tcpAddr.Port, nil +} + +func startAnvil(ctx context.Context, l2RPCURL string, targetBlock uint64) (string, func(), error) { + port, err := findFreePort() + if err != nil { + return "", nil, fmt.Errorf("find free port: %w", err) + } + + cmd := exec.CommandContext(ctx, "anvil", + "--fork-url", l2RPCURL, + "--fork-block-number", fmt.Sprintf("%d", targetBlock), + "--port", fmt.Sprintf("%d", port), + "--silent", + // Batch mining: with auto-mine each bridgeAsset would mine its own block, so a mainnet replay + // (hundreds of thousands of exits) accumulates that many blocks and Anvil degrades until + // receipt polling times out. Instead Anvil mines on a fixed interval (--block-time), batching + // all txs pending at each tick into one block. --disable-block-gas-limit lets a single block + // hold every pending tx regardless of their (explicit) gas limits. + "--block-time", strconv.Itoa(anvilBlockTimeSeconds), + "--disable-block-gas-limit", + // Accept eth_sendTransaction from any account without a per-tx anvil_impersonateAccount call. + // The replay only needs each sender's balance set once (see replayBridgeExits), so this drops + // two RPC round-trips per replayed tx. + "--auto-impersonate", + // Fork-backend resilience: replaying against a remote RPC triggers many lazy state fetches; + // the upstream intermittently drops connections. Let Anvil retry those fetches with backoff + // and a generous timeout before surfacing a Fork Error. + "--retries", "10", + "--fork-retry-backoff", "1000", + "--timeout", "120000", + // Anvil self-throttles requests to the fork backend to ~330 compute-units/s by default, which + // caps cold-state fetches globally (independent of our concurrency) to a few exits/s. Disable + // it so the replay is bound by the upstream RPC's real capacity, not Anvil's internal limiter. + "--no-rate-limit", + ) + if err := cmd.Start(); err != nil { + return "", nil, fmt.Errorf("start anvil process: %w", err) + } + + cleanup := func() { + if cmd.Process != nil { + _ = cmd.Process.Kill() + _ = cmd.Wait() + } + } + + anvilURL := fmt.Sprintf("http://127.0.0.1:%d", port) + if err := waitForAnvil(ctx, anvilURL); err != nil { + cleanup() + return "", nil, err + } + log.Infof("Anvil fork ready at %s (block %d)", anvilURL, targetBlock) + return anvilURL, cleanup, nil +} + +func waitForAnvil(ctx context.Context, anvilURL string) error { + deadline := time.Now().Add(anvilReadyTimeout) + for time.Now().Before(deadline) { + if _, err := singleRPC(ctx, anvilURL, "eth_blockNumber", nil, 1); err == nil { + return nil + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(anvilPollInterval): + } + } + return fmt.Errorf("anvil not ready after %s", anvilReadyTimeout) +} + +// setSenderBalance funds sender with largeETHBalance so its bridgeAsset calls (native value + gas) +// never fail on insufficient funds. Anvil runs with --auto-impersonate, so no impersonation call is +// needed; the balance only has to be set once per sender (it stays large across that sender's exits). +func setSenderBalance(ctx context.Context, anvilURL string, sender common.Address) error { + if _, err := singleRPC(ctx, anvilURL, "anvil_setBalance", + []any{sender.Hex(), largeETHBalance}, defaultRetries); err != nil { + return fmt.Errorf("set balance: %w", err) + } + return nil +} + +// buildLBTTokenMap builds a lookup map from (originNetwork, originToken) to wrapped address +// using the LBT entries produced by Step 0. Returns an empty map when entries is nil. +func buildLBTTokenMap(entries []LBTEntry) map[tokenOriginKey]common.Address { + m := make(map[tokenOriginKey]common.Address, len(entries)) + for _, e := range entries { + if e.WrappedTokenAddress != (common.Address{}) { + m[tokenOriginKey{e.OriginNetwork, e.OriginTokenAddress}] = e.WrappedTokenAddress + } + } + return m +} + +// resolveTokenAddresses returns a map from origin token identity to its L2 ERC-20 address. +// Native tokens (ETH and custom gas token) are omitted — callers use isNativeBridgeExit to +// distinguish them. L2-native tokens map to their own address; external-origin tokens are +// resolved first from lbtMap (Step 0 output) and fall back to getTokenWrappedAddress on the +// bridge contract when not present. +func resolveTokenAddresses( + ctx context.Context, backend forkBackend, + exits []*agglayertypes.BridgeExit, l2NetworkID uint32, + gasTokenNetwork uint32, gasTokenAddress common.Address, + lbtMap map[tokenOriginKey]common.Address, +) (map[tokenOriginKey]common.Address, error) { + result := make(map[tokenOriginKey]common.Address) + + for _, be := range exits { + ti := be.TokenInfo + // Skip native tokens — no ERC-20 address to look up. Checked before building the key because a + // native exit may carry a nil TokenInfo (isNativeBridgeExit handles that). + if isNativeBridgeExit(ti, gasTokenNetwork, gasTokenAddress) { + continue + } + key := tokenOriginKey{ti.OriginNetwork, ti.OriginTokenAddress} + if _, ok := result[key]; ok { + continue // already resolved + } + // L2-native token — its L2 address is the origin address itself. + if ti.OriginNetwork == l2NetworkID { + result[key] = ti.OriginTokenAddress + continue + } + // External-origin wrapped token — prefer the LBT map (already accounts for + // SetSovereignTokenAddress overrides), fall back to the bridge contract. + if wrapped, ok := lbtMap[key]; ok { + log.Debugf("token resolved from LBT: origin(network=%d addr=%s) -> %s", + ti.OriginNetwork, ti.OriginTokenAddress.Hex(), wrapped.Hex()) + result[key] = wrapped + continue + } + wrapped, err := backend.TokenWrappedAddress(ctx, ti.OriginNetwork, ti.OriginTokenAddress) + if err != nil { + return nil, fmt.Errorf("getTokenWrappedAddress(net=%d addr=%s): %w", + ti.OriginNetwork, ti.OriginTokenAddress.Hex(), err) + } + if wrapped == (common.Address{}) { + return nil, fmt.Errorf("no wrapped token on L2 for origin network=%d addr=%s", + ti.OriginNetwork, ti.OriginTokenAddress.Hex()) + } + log.Debugf("token resolved from contract: origin(network=%d addr=%s) -> %s", + ti.OriginNetwork, ti.OriginTokenAddress.Hex(), wrapped.Hex()) + result[key] = wrapped + } + return result, nil +} + +func callGetTokenWrappedAddress( + ctx context.Context, anvilURL string, bridgeAddr common.Address, + originNetwork uint32, originTokenAddr common.Address, +) (common.Address, error) { + callData, err := bridgeABI.Pack("getTokenWrappedAddress", originNetwork, originTokenAddr) + if err != nil { + return common.Address{}, fmt.Errorf("pack getTokenWrappedAddress: %w", err) + } + raw, err := singleRPC(ctx, anvilURL, "eth_call", []any{ + map[string]any{"to": bridgeAddr.Hex(), "data": "0x" + hex.EncodeToString(callData)}, + "latest", + }, defaultRetries) + if err != nil { + return common.Address{}, err + } + var hexStr string + if err := json.Unmarshal(raw, &hexStr); err != nil { + return common.Address{}, fmt.Errorf("parse eth_call result: %w", err) + } + b, err := hex.DecodeString(strings.TrimPrefix(hexStr, "0x")) + if err != nil { + return common.Address{}, fmt.Errorf("decode hex result: %w", err) + } + results, err := bridgeABI.Unpack("getTokenWrappedAddress", b) + if err != nil { + return common.Address{}, fmt.Errorf("unpack getTokenWrappedAddress: %w", err) + } + addr, ok := results[0].(common.Address) + if !ok { + return common.Address{}, fmt.Errorf("unexpected return type for getTokenWrappedAddress") + } + return addr, nil +} + +// callGetTokenMetadata calls getTokenMetadata(token) on the bridge contract at rpcURL and returns the +// metadata blob the contract embeds in a bridgeAsset leaf for that token (the ABI-encoded name, +// symbol and decimals). +func callGetTokenMetadata( + ctx context.Context, rpcURL string, bridgeAddr, token common.Address, +) ([]byte, error) { + callData, err := bridgeABI.Pack("getTokenMetadata", token) + if err != nil { + return nil, fmt.Errorf("pack getTokenMetadata: %w", err) + } + return ethCallBytes(ctx, rpcURL, bridgeAddr, callData, "getTokenMetadata") +} + +// callGasTokenMetadata calls gasTokenMetadata() on the bridge contract at rpcURL and returns the +// metadata the contract embeds in a leaf for a native (gas token) bridge — empty when the gas token +// is ETH (no custom gas token). +func callGasTokenMetadata(ctx context.Context, rpcURL string, bridgeAddr common.Address) ([]byte, error) { + callData, err := bridgeABI.Pack("gasTokenMetadata") + if err != nil { + return nil, fmt.Errorf("pack gasTokenMetadata: %w", err) + } + return ethCallBytes(ctx, rpcURL, bridgeAddr, callData, "gasTokenMetadata") +} + +// ethCallBytes eth_calls bridgeAddr with callData at latest and unpacks the single `bytes` return +// value of method. Shared by callGetTokenMetadata and callGasTokenMetadata. +func ethCallBytes( + ctx context.Context, rpcURL string, bridgeAddr common.Address, callData []byte, method string, +) ([]byte, error) { + raw, err := singleRPC(ctx, rpcURL, "eth_call", []any{ + map[string]any{"to": bridgeAddr.Hex(), "data": "0x" + hex.EncodeToString(callData)}, + "latest", + }, defaultRetries) + if err != nil { + return nil, err + } + var hexStr string + if err := json.Unmarshal(raw, &hexStr); err != nil { + return nil, fmt.Errorf("parse %s result: %w", method, err) + } + b, err := hex.DecodeString(strings.TrimPrefix(hexStr, "0x")) + if err != nil { + return nil, fmt.Errorf("decode %s hex: %w", method, err) + } + results, err := bridgeABI.Unpack(method, b) + if err != nil { + return nil, fmt.Errorf("unpack %s: %w", method, err) + } + metadata, ok := results[0].([]byte) + if !ok { + return nil, fmt.Errorf("unexpected return type for %s", method) + } + return metadata, nil +} + +// erc20NamespacedStorageLocation is the ERC-20 storage namespace for OZ v5 upgradeable tokens. +var erc20NamespacedStorageLocation = common.HexToHash( + "0x52c63247e1f47db19d5ce0460030c497f067ca4cebf71ba98eeadabe20bace00", +) + +// ensureERC20Balance checks the ERC-20 balance of account on tokenAddr. +// If insufficient it patches _balances[account] via hardhat_setStorageAt. +// Tries two storage layouts in order, verifying balanceOf after each patch: +// 1. OZ v4 non-upgradeable: _balances at mapping slot 0 +// 2. OZ v5 upgradeable: _balances inside the namespaced ERC20Storage struct +func ensureERC20Balance( + ctx context.Context, rpcURL string, tokenAddr, account common.Address, required *big.Int, +) error { + balanceOf := func() (*big.Int, error) { + callData := make([]byte, abiFuncSelectorSize+abiWordBytes) + copy(callData, crypto.Keccak256([]byte("balanceOf(address)"))[:abiFuncSelectorSize]) + copy(callData[abiFuncSelectorSize:], common.LeftPadBytes(account.Bytes(), abiWordBytes)) + raw, err := singleRPC(ctx, rpcURL, "eth_call", []any{ + map[string]any{"to": tokenAddr.Hex(), "data": "0x" + hex.EncodeToString(callData)}, + "latest", + }, defaultRetries) + if err != nil { + return nil, fmt.Errorf("balanceOf(%s): %w", account.Hex(), err) + } + var hexBal string + if err := json.Unmarshal(raw, &hexBal); err != nil { + return nil, fmt.Errorf("parse balanceOf result: %w", err) + } + bal, ok := new(big.Int).SetString(strings.TrimPrefix(hexBal, "0x"), hexBase) + if !ok { + return nil, fmt.Errorf("invalid balanceOf hex: %s", hexBal) + } + return bal, nil + } + + bal, err := balanceOf() + if err != nil { + return err + } + if bal.Cmp(required) >= 0 { + log.Debugf("ERC-20 %s balance of %s is sufficient (%s >= %s)", tokenAddr.Hex(), account.Hex(), bal, required) + return nil + } + + log.Debugf("ERC-20 %s balance of %s insufficient (%s < %s) — patching via storage slot", + tokenAddr.Hex(), account.Hex(), bal, required) + + valueHex := "0x" + hex.EncodeToString(common.LeftPadBytes(required.Bytes(), abiWordBytes)) + + // erc20BalanceSlot returns keccak256(abi.encode(account, mapSlot)), + // which is the Solidity storage slot for _balances[account] when _balances + // is a mapping located at mapSlot. + erc20BalanceSlot := func(mapSlot common.Hash) string { + preimage := append( + common.LeftPadBytes(account.Bytes(), abiWordBytes), + mapSlot.Bytes()..., + ) + return "0x" + hex.EncodeToString(crypto.Keccak256(preimage)) + } + + // Try OZ v4 (slot 0) first, then OZ v5 upgradeable (namespaced storage). + candidates := []string{ + erc20BalanceSlot(common.Hash{}), // OZ v4: _balances at slot 0 + erc20BalanceSlot(erc20NamespacedStorageLocation), // OZ v5 upgradeable + } + + for _, slotHex := range candidates { + if _, err := singleRPC(ctx, rpcURL, "hardhat_setStorageAt", + []any{tokenAddr.Hex(), slotHex, valueHex}, defaultRetries); err != nil { + return fmt.Errorf("set ERC-20 balance storage slot: %w", err) + } + newBal, err := balanceOf() + if err != nil { + return err + } + if newBal.Cmp(required) >= 0 { + log.Debugf("✅ ERC-20 %s balance of %s patched to %s (slot %s)", + tokenAddr.Hex(), account.Hex(), required, slotHex) + return nil + } + log.Debugf("slot %s did not update balanceOf — trying next layout", slotHex) + } + + return fmt.Errorf("could not patch ERC-20 balance for token %s account %s: "+ + "no storage layout matched (tried OZ v4 slot-0 and OZ v5 upgradeable)", + tokenAddr.Hex(), account.Hex()) +} + +// encodeERC20ApproveCallRaw ABI-encodes an ERC-20 approve(spender, amount) call. +// Selector: keccak256("approve(address,uint256)")[:4] = 0x095ea7b3 +func encodeERC20ApproveCallRaw(spender common.Address, amount *big.Int) []byte { + if amount == nil { + amount = new(big.Int) + } + selector := crypto.Keccak256([]byte("approve(address,uint256)"))[:4] + encodedSpender := common.LeftPadBytes(spender.Bytes(), abiWordBytes) + encodedAmount := common.LeftPadBytes(amount.Bytes(), abiWordBytes) + return append(selector, append(encodedSpender, encodedAmount...)...) +} + +func encodeBridgeAssetCallRaw( + destNetwork uint32, destAddr common.Address, amount *big.Int, tokenAddr common.Address, +) []byte { + if amount == nil { + amount = new(big.Int) + } + // forceUpdateGlobalExitRoot=false (per the Step G spec): the local exit tree leaf — and thus + // getRoot()/NewLocalExitRoot — is inserted regardless of this flag. Setting it true would push a + // GlobalExitRoot update (extra, variable-cost SSTOREs) on every exit, inflating gas and the + // estimation variance for no benefit here. + data, err := bridgeABI.Pack("bridgeAsset", destNetwork, destAddr, amount, tokenAddr, false, []byte{}) + if err != nil { + // Static types match the ABI; Pack only fails on type mismatches, which cannot happen here. + panic(fmt.Sprintf("pack bridgeAsset: %v", err)) + } + return data +} + +func sendAnvilTransaction( + ctx context.Context, anvilURL string, + from, to common.Address, value *big.Int, data []byte, +) (common.Hash, error) { + tx := map[string]any{ + "from": from.Hex(), + "to": to.Hex(), + "data": "0x" + hex.EncodeToString(data), + // Explicit gas limit: do not let Anvil auto-estimate (see anvilTxGasLimit) — concurrent + // estimation races the global depositCount and under-estimates, causing out-of-gas reverts. + "gas": anvilTxGasLimit, + } + if value != nil && value.Sign() > 0 { + tx["value"] = "0x" + value.Text(hexBase) + } + var result json.RawMessage + var err error + for attempt := 1; ; attempt++ { + result, err = singleRPC(ctx, anvilURL, "eth_sendTransaction", []any{tx}, defaultRetries) + if err == nil { + break + } + // A remote fork backend can drop a fetch while Anvil resolves state for this tx; the send + // never landed, so retrying is safe. Bounded retries with backoff; real errors fail at once. + if !isTransientForkError(err) || attempt >= forkRetryAttempts { + return common.Hash{}, err + } + log.Debugf("transient fork error sending tx (attempt %d/%d, retrying): %v", attempt, forkRetryAttempts, err) + select { + case <-ctx.Done(): + return common.Hash{}, ctx.Err() + case <-time.After(forkRetryBackoff): + } + } + log.Debugf("eth_sendTransaction raw result: %s", string(result)) + var txHashHex string + if err := json.Unmarshal(result, &txHashHex); err != nil { + return common.Hash{}, fmt.Errorf("parse tx hash: %w", err) + } + return common.HexToHash(txHashHex), nil +} + +func waitForReceipt(ctx context.Context, anvilURL string, txHash common.Hash) ([]rpcLog, error) { + deadline := time.Now().Add(receiptPollTimeout) + for time.Now().Before(deadline) { + result, err := singleRPC(ctx, anvilURL, "eth_getTransactionReceipt", + []any{txHash.Hex()}, defaultRetries) + if err != nil { + // A remote fork backend hiccups under load (dropped connection / timeout); these are + // transient, so keep polling within the deadline instead of aborting the whole replay. + if isTransientForkError(err) { + log.Debugf("transient fork error polling receipt %s (retrying): %v", txHash.Hex(), err) + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(receiptPollInterval): + continue + } + } + return nil, err + } + if len(result) == 0 || string(result) == "null" { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(receiptPollInterval): + continue + } + } + var receipt struct { + Status string `json:"status"` + BlockNumber string `json:"blockNumber"` + Logs []rpcLog `json:"logs"` + } + if err := json.Unmarshal(result, &receipt); err != nil { + return nil, fmt.Errorf("parse receipt: %w", err) + } + if receipt.Status == "0x0" { + reason := fetchRevertReason(ctx, anvilURL, txHash, receipt.BlockNumber) + return nil, fmt.Errorf("transaction %s reverted: %s", txHash.Hex(), reason) + } + return receipt.Logs, nil + } + return nil, fmt.Errorf("%w of %s", errReceiptTimeout, txHash.Hex()) +} + +// replayedLeafFromReceipt finds the BridgeEvent log in a replayed bridgeAsset's receipt logs and +// builds the bridgesyncerlite.BridgeLeaf for it, carrying the on-chain depositCount (the canonical +// exit-tree position), the leaf content, the metadata, and the block position. txHash is the +// replaying transaction. The leaf is both inserted into the lite DB (no second fork pass) and used +// to reorder the certificate by depositCount. +func replayedLeafFromReceipt(logs []rpcLog, txHash common.Hash) (bridgesyncerlite.BridgeLeaf, error) { + for _, l := range logs { + event, matched, err := parseBridgeEventLog(l.Topics, l.Data) + if err != nil { + return bridgesyncerlite.BridgeLeaf{}, err + } + if !matched { + continue + } + return bridgesyncerlite.BridgeLeaf{ + BlockNum: hexToUint64(l.BlockNumber), + BlockPos: hexToUint64(l.LogIndex), + LeafType: event.LeafType, + OriginNetwork: event.OriginNetwork, + OriginAddress: event.OriginAddress, + DestinationNetwork: event.DestinationNetwork, + DestinationAddress: event.DestinationAddress, + Amount: event.Amount, + Metadata: event.Metadata, + DepositCount: event.DepositCount, + TxHash: txHash, + }, nil + } + return bridgesyncerlite.BridgeLeaf{}, fmt.Errorf("BridgeEvent not found in receipt logs") +} + +// parseBridgeEventLog decodes a single log's topics/data into a bridgeEventLog. It returns +// matched=false (with no error) when the log is not a BridgeEvent, so callers can skip it. +func parseBridgeEventLog(topics []string, data string) (*bridgeEventLog, bool, error) { + if len(topics) == 0 || !strings.EqualFold(topics[0], bridgeEventTopicHash.Hex()) { + return nil, false, nil + } + raw, err := hex.DecodeString(strings.TrimPrefix(data, "0x")) + if err != nil { + return nil, false, fmt.Errorf("decode BridgeEvent data: %w", err) + } + values, err := bridgeABI.Events["BridgeEvent"].Inputs.UnpackValues(raw) + if err != nil { + return nil, false, fmt.Errorf("unpack BridgeEvent: %w", err) + } + if len(values) != bridgeEventFields { + return nil, false, fmt.Errorf("expected %d BridgeEvent fields, got %d", bridgeEventFields, len(values)) + } + leafType, ok0 := values[0].(uint8) + originNetwork, ok1 := values[1].(uint32) + originAddress, ok2 := values[2].(common.Address) + destNetwork, ok3 := values[3].(uint32) + destAddress, ok4 := values[4].(common.Address) + amount, ok5 := values[5].(*big.Int) + metadata, ok6 := values[6].([]byte) + depositCount, ok7 := values[7].(uint32) + if !ok0 || !ok1 || !ok2 || !ok3 || !ok4 || !ok5 || !ok6 || !ok7 { + return nil, false, fmt.Errorf("unexpected field types in BridgeEvent values") + } + return &bridgeEventLog{ + LeafType: leafType, + OriginNetwork: originNetwork, + OriginAddress: originAddress, + DestinationNetwork: destNetwork, + DestinationAddress: destAddress, + Amount: amount, + Metadata: metadata, + DepositCount: depositCount, + }, true, nil +} + +// knownErrors maps 4-byte selector (hex, no 0x) to signature and argument decoder. +var knownErrors = map[string]struct { + sig string + decode func(args []byte) string +}{ + // LocalBalanceTreeUnderflow(uint32,address,uint256,uint256) + "14603c01": { + sig: "LocalBalanceTreeUnderflow(uint32,address,uint256,uint256)", + decode: func(args []byte) string { + if len(args) < fourABIWords { + return "" + } + network := uint32(new(big.Int).SetBytes(args[0:32]).Uint64()) + addr := common.BytesToAddress(args[32:64]) + balance := new(big.Int).SetBytes(args[64:96]) + available := new(big.Int).SetBytes(args[96:128]) + return fmt.Sprintf("network=%d addr=%s balance=%s available=%s", + network, addr.Hex(), balance, available) + }, + }, +} + +// decodeRevertData tries to match the 4-byte selector of hexData against knownErrors +// and returns a human-readable string. Falls back to the raw hex if unknown. +func decodeRevertData(hexData string) string { + data, err := hex.DecodeString(strings.TrimPrefix(hexData, "0x")) + if err != nil || len(data) < 4 { + return hexData + } + selector := hex.EncodeToString(data[:4]) + entry, ok := knownErrors[selector] + if !ok { + return fmt.Sprintf("unknown selector 0x%s data=%s", selector, hexData) + } + decoded := entry.decode(data[4:]) + if decoded == "" { + return fmt.Sprintf("%s [0x%s] (raw: %s)", entry.sig, selector, hexData) + } + return fmt.Sprintf("%s [0x%s]: %s", entry.sig, selector, decoded) +} + +// fetchRevertReason replays the failed transaction via eth_call at the block it was +// mined in order to extract the revert reason from the JSON-RPC error message. +func fetchRevertReason(ctx context.Context, anvilURL string, txHash common.Hash, blockNumber string) string { + raw, err := singleRPC(ctx, anvilURL, "eth_getTransactionByHash", []any{txHash.Hex()}, 1) + if err != nil { + return fmt.Sprintf("(could not fetch tx: %v)", err) + } + var tx struct { + From string `json:"from"` + To string `json:"to"` + Input string `json:"input"` + Value string `json:"value"` + } + if err := json.Unmarshal(raw, &tx); err != nil { + return fmt.Sprintf("(could not parse tx: %v)", err) + } + callParams := map[string]any{ + "from": tx.From, + "to": tx.To, + "data": tx.Input, + } + if tx.Value != "" && tx.Value != "0x0" && tx.Value != "0x" { + callParams["value"] = tx.Value + } + block := blockNumber + if block == "" { + block = "latest" + } + _, callErr := singleRPC(ctx, anvilURL, "eth_call", []any{callParams, block}, 1) + if callErr == nil { + return "no revert reason available" + } + var rpcErr *RPCExecutionError + if errors.As(callErr, &rpcErr) && rpcErr.Data != "" { + return decodeRevertData(rpcErr.Data) + } + return callErr.Error() +} + +// readLocalExitRoot calls getRoot() on the bridge contract to get the LER at blockTag. +func readLocalExitRoot( + ctx context.Context, rpcURL string, bridgeAddr common.Address, blockTag string, +) (common.Hash, error) { + callData, err := bridgeABI.Pack("getRoot") + if err != nil { + return common.Hash{}, fmt.Errorf("pack getRoot: %w", err) + } + raw, err := singleRPC(ctx, rpcURL, "eth_call", []any{ + map[string]any{ + "to": bridgeAddr.Hex(), + "data": "0x" + hex.EncodeToString(callData), + }, + blockTag, + }, defaultRetries) + if err != nil { + return common.Hash{}, err + } + var hexStr string + if err := json.Unmarshal(raw, &hexStr); err != nil { + return common.Hash{}, fmt.Errorf("parse getRoot result: %w", err) + } + b, err := hex.DecodeString(strings.TrimPrefix(hexStr, "0x")) + if err != nil { + return common.Hash{}, fmt.Errorf("decode getRoot hex: %w", err) + } + results, err := bridgeABI.Unpack("getRoot", b) + if err != nil { + return common.Hash{}, fmt.Errorf("unpack getRoot: %w", err) + } + hash, ok := results[0].([32]byte) + if !ok { + return common.Hash{}, fmt.Errorf("unexpected return type for getRoot") + } + return common.Hash(hash), nil +} diff --git a/tools/exit_certificate/step_g2_helpers_test.go b/tools/exit_certificate/step_g2_helpers_test.go new file mode 100644 index 000000000..d4343ea83 --- /dev/null +++ b/tools/exit_certificate/step_g2_helpers_test.go @@ -0,0 +1,202 @@ +package exit_certificate + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "math/big" + "os" + "path/filepath" + "testing" + "time" + + agglayertypes "github.com/agglayer/aggkit/agglayer/types" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +func TestIsContextCanceled(t *testing.T) { + t.Parallel() + require.False(t, isContextCanceled(nil)) + require.False(t, isContextCanceled(errors.New("other"))) + require.True(t, isContextCanceled(context.Canceled)) + require.True(t, isContextCanceled(fmt.Errorf("wrapped: %w", context.Canceled))) +} + +func TestIsTransientForkError(t *testing.T) { + t.Parallel() + require.False(t, isTransientForkError(nil)) + require.False(t, isTransientForkError(context.Canceled)) + // a genuine revert is never transient, even if it mentions a marker word + require.False(t, isTransientForkError(errors.New("execution reverted: connection"))) + + for _, msg := range []string{ + "Fork Error: backend unreachable", + "transport closed", + "dispatch failure", + "request timeout", + "connection reset by peer", + "unexpected EOF", + } { + require.True(t, isTransientForkError(errors.New(msg)), msg) + } + require.False(t, isTransientForkError(errors.New("invalid opcode"))) +} + +func TestParseBridgeEventLogRoundTrip(t *testing.T) { + t.Parallel() + want := bridgeEventLog{ + LeafType: 1, + OriginNetwork: 5, + OriginAddress: common.HexToAddress("0xorigin"), + DestinationNetwork: 0, + DestinationAddress: common.HexToAddress("0xdest"), + Amount: big.NewInt(123456), + Metadata: []byte{0xde, 0xad}, + DepositCount: 9, + } + data, err := bridgeABI.Events["BridgeEvent"].Inputs.Pack( + want.LeafType, want.OriginNetwork, want.OriginAddress, want.DestinationNetwork, + want.DestinationAddress, want.Amount, want.Metadata, want.DepositCount, + ) + require.NoError(t, err) + hexData := "0x" + common.Bytes2Hex(data) + + // non-matching topic → matched=false, no error + got, matched, err := parseBridgeEventLog([]string{common.HexToHash("0xdead").Hex()}, hexData) + require.NoError(t, err) + require.False(t, matched) + require.Nil(t, got) + + // empty topics → matched=false + _, matched, err = parseBridgeEventLog(nil, hexData) + require.NoError(t, err) + require.False(t, matched) + + // matching topic → decoded + got, matched, err = parseBridgeEventLog([]string{bridgeEventTopicHash.Hex()}, hexData) + require.NoError(t, err) + require.True(t, matched) + require.Equal(t, want.LeafType, got.LeafType) + require.Equal(t, want.OriginNetwork, got.OriginNetwork) + require.Equal(t, want.OriginAddress, got.OriginAddress) + require.Equal(t, want.DestinationAddress, got.DestinationAddress) + require.Equal(t, want.Amount, got.Amount) + require.Equal(t, want.Metadata, got.Metadata) + require.Equal(t, want.DepositCount, got.DepositCount) + + // matching topic but garbage data → error + _, _, err = parseBridgeEventLog([]string{bridgeEventTopicHash.Hex()}, "0xzznothex") + require.Error(t, err) +} + +func TestReplayedLeafFromReceipt(t *testing.T) { + t.Parallel() + ev := bridgeEventLog{ + LeafType: 0, OriginNetwork: 1, OriginAddress: common.HexToAddress("0xo"), + DestinationNetwork: 0, DestinationAddress: common.HexToAddress("0xd"), + Amount: big.NewInt(77), Metadata: []byte{0x01}, DepositCount: 4, + } + data, err := bridgeABI.Events["BridgeEvent"].Inputs.Pack( + ev.LeafType, ev.OriginNetwork, ev.OriginAddress, ev.DestinationNetwork, + ev.DestinationAddress, ev.Amount, ev.Metadata, ev.DepositCount, + ) + require.NoError(t, err) + + txHash := common.HexToHash("0xtx") + logs := []rpcLog{ + {Topics: []string{common.HexToHash("0xunrelated").Hex()}, Data: "0x"}, // skipped + { + Topics: []string{bridgeEventTopicHash.Hex()}, + Data: "0x" + common.Bytes2Hex(data), + BlockNumber: "0x10", + LogIndex: "0x2", + }, + } + leaf, err := replayedLeafFromReceipt(logs, txHash) + require.NoError(t, err) + require.Equal(t, ev.DepositCount, leaf.DepositCount) + require.Equal(t, txHash, leaf.TxHash) + require.Equal(t, uint64(16), leaf.BlockNum) + require.Equal(t, uint64(2), leaf.BlockPos) + require.Equal(t, ev.Amount, leaf.Amount) + + // no BridgeEvent present → error + _, err = replayedLeafFromReceipt([]rpcLog{{Topics: []string{common.HexToHash("0xnope").Hex()}}}, txHash) + require.Error(t, err) +} + +func TestDecodeRevertData(t *testing.T) { + t.Parallel() + // invalid hex / too short → returns input verbatim + require.Equal(t, "0xzz", decodeRevertData("0xzz")) + require.Equal(t, "0x01", decodeRevertData("0x01")) + + // unknown selector + out := decodeRevertData("0xdeadbeef") + require.Contains(t, out, "unknown selector") + + // known error: LocalBalanceTreeUnderflow(uint32,address,uint256,uint256) + args := make([]byte, 4*32) + args[31] = 3 // network=3 in the first word + payload := append([]byte{0x14, 0x60, 0x3c, 0x01}, args...) + out = decodeRevertData("0x" + common.Bytes2Hex(payload)) + require.Contains(t, out, "LocalBalanceTreeUnderflow") + require.Contains(t, out, "network=3") + + // known selector but truncated args → falls back to sig + raw + short := append([]byte{0x14, 0x60, 0x3c, 0x01}, 0x00) + out = decodeRevertData("0x" + common.Bytes2Hex(short)) + require.Contains(t, out, "LocalBalanceTreeUnderflow") +} + +func TestLogReplayProgress(t *testing.T) { + t.Parallel() + // purely exercises the logging/eta math; just must not panic + require.NotPanics(t, func() { + logReplayProgress(5, 10, time.Now().Add(-2*time.Second)) + logReplayProgress(0, 10, time.Now()) // rate 0 branch + }) +} + +func TestFindFreePort(t *testing.T) { + t.Parallel() + p, err := findFreePort() + require.NoError(t, err) + require.Greater(t, p, 0) +} + +func TestCheckAnvilAvailable(t *testing.T) { + t.Parallel() + // Result depends on whether anvil is installed; just verify it returns a typed result. + if err := checkAnvilAvailable(); err != nil { + require.Contains(t, err.Error(), "anvil not found") + } +} + +func TestSaveFailedExit(t *testing.T) { + t.Parallel() + dir := t.TempDir() + job := exitJob{ + index: 2, + isNative: false, + l2TokenAddr: common.HexToAddress("0xtoken"), + bridge: &agglayertypes.BridgeExit{ + TokenInfo: &agglayertypes.TokenInfo{OriginNetwork: 1, OriginTokenAddress: common.HexToAddress("0xorigin")}, + DestinationNetwork: 0, + DestinationAddress: common.HexToAddress("0xdest"), + Amount: big.NewInt(500), + }, + } + saveFailedExit(dir, job, errors.New("replay blew up")) + + raw, err := os.ReadFile(filepath.Join(dir, "step-g-failed-exit.json")) + require.NoError(t, err) + var fe FailedBridgeExit + require.NoError(t, json.Unmarshal(raw, &fe)) + require.Equal(t, 2, fe.Index) + require.Equal(t, "replay blew up", fe.Error) + require.Equal(t, uint32(1), fe.OriginNetwork) + require.Equal(t, "500", fe.Amount) +} diff --git a/tools/exit_certificate/step_g2_metadata_test.go b/tools/exit_certificate/step_g2_metadata_test.go new file mode 100644 index 000000000..f88b46935 --- /dev/null +++ b/tools/exit_certificate/step_g2_metadata_test.go @@ -0,0 +1,126 @@ +package exit_certificate + +import ( + "context" + "encoding/json" + "math/big" + "testing" + + agglayertypes "github.com/agglayer/aggkit/agglayer/types" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +func TestCallGetTokenMetadata(t *testing.T) { + t.Parallel() + bridge := common.HexToAddress("0xbridge") + token := common.HexToAddress("0xtoken") + want := []byte{0xde, 0xad, 0xbe, 0xef} + out, err := bridgeABI.Methods["getTokenMetadata"].Outputs.Pack(want) + require.NoError(t, err) + + srv := newRPCStub(t, func(method string, _ []any) (json.RawMessage, *jsonRPCError) { + require.Equal(t, rpcMethodEthCall, method) + return hexResult(out), nil + }) + got, err := callGetTokenMetadata(context.Background(), srv.URL, bridge, token) + require.NoError(t, err) + require.Equal(t, want, got) +} + +func TestCallGasTokenMetadata(t *testing.T) { + t.Parallel() + bridge := common.HexToAddress("0xbridge") + out, err := bridgeABI.Methods["gasTokenMetadata"].Outputs.Pack([]byte{}) + require.NoError(t, err) + + srv := newRPCStub(t, func(string, []any) (json.RawMessage, *jsonRPCError) { + return hexResult(out), nil + }) + got, err := callGasTokenMetadata(context.Background(), srv.URL, bridge) + require.NoError(t, err) + require.Empty(t, got) +} + +func TestGenerateMetadataNativeAndERC20(t *testing.T) { + t.Parallel() + backend := newMockBackend() + backend.gasTokenMeta = func(context.Context) ([]byte, error) { return []byte{}, nil } + backend.tokenMeta = func(_ context.Context, _ common.Address) ([]byte, error) { + return []byte{0x01, 0x02}, nil + } + + origin := common.HexToAddress("0x00000000000000000000000000000000000000aa") + wrapped := common.HexToAddress("0x00000000000000000000000000000000000000bb") + cert := &agglayertypes.Certificate{ + BridgeExits: []*agglayertypes.BridgeExit{ + nativeAssetExit(common.HexToAddress("0xdead"), 5), + { + TokenInfo: &agglayertypes.TokenInfo{OriginNetwork: 99, OriginTokenAddress: origin}, + DestinationNetwork: 0, + DestinationAddress: common.HexToAddress("0xbeef"), + Amount: big.NewInt(7), + }, + }, + } + // LBT maps the external-origin token to its L2 wrapped address (avoids a getTokenWrappedAddress RPC). + lbt := []LBTEntry{{OriginNetwork: 99, OriginTokenAddress: origin, WrappedTokenAddress: wrapped}} + cfg := &Config{L2NetworkID: 1} + + metas, err := generateMetadata(context.Background(), backend, cfg, cert, lbt) + require.NoError(t, err) + require.Len(t, metas, 2) + require.Empty(t, metas[0]) // native → gas token metadata (empty) + require.Equal(t, []byte{0x01, 0x02}, metas[1]) // ERC-20 → getTokenMetadata +} + +func TestWaitForAnvilReady(t *testing.T) { + t.Parallel() + srv := newRPCStub(t, func(method string, _ []any) (json.RawMessage, *jsonRPCError) { + require.Equal(t, rpcMethodEthBlockNumber, method) + return quoted("0x1"), nil + }) + require.NoError(t, waitForAnvil(context.Background(), srv.URL)) +} + +func TestWaitForAnvilContextCancelled(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithCancel(context.Background()) + cancel() // first probe against an unreachable URL fails, then the select sees ctx.Done + err := waitForAnvil(ctx, "http://127.0.0.1:1") + require.ErrorIs(t, err, context.Canceled) +} + +func TestEthCallBytesErrors(t *testing.T) { + t.Parallel() + bridge := common.HexToAddress("0xbridge") + callData, err := bridgeABI.Pack("gasTokenMetadata") + require.NoError(t, err) + + t.Run("rpc error", func(t *testing.T) { + t.Parallel() + srv := newRPCStub(t, func(string, []any) (json.RawMessage, *jsonRPCError) { + return nil, revertErr() + }) + _, err := ethCallBytes(context.Background(), srv.URL, bridge, callData, "gasTokenMetadata") + require.Error(t, err) + }) + + t.Run("invalid hex result", func(t *testing.T) { + t.Parallel() + srv := newRPCStub(t, func(string, []any) (json.RawMessage, *jsonRPCError) { + return quoted("0xzz"), nil + }) + _, err := ethCallBytes(context.Background(), srv.URL, bridge, callData, "gasTokenMetadata") + require.ErrorContains(t, err, "decode gasTokenMetadata hex") + }) + + t.Run("undecodable abi", func(t *testing.T) { + t.Parallel() + srv := newRPCStub(t, func(string, []any) (json.RawMessage, *jsonRPCError) { + return quoted("0x00"), nil // too short to unpack a bytes return + }) + _, err := ethCallBytes(context.Background(), srv.URL, bridge, callData, "gasTokenMetadata") + require.ErrorContains(t, err, "unpack gasTokenMetadata") + }) +} diff --git a/tools/exit_certificate/step_g2_replay_test.go b/tools/exit_certificate/step_g2_replay_test.go new file mode 100644 index 000000000..72ae46df8 --- /dev/null +++ b/tools/exit_certificate/step_g2_replay_test.go @@ -0,0 +1,484 @@ +package exit_certificate + +import ( + "context" + "errors" + "fmt" + "math/big" + "sync" + "testing" + + agglayertypes "github.com/agglayer/aggkit/agglayer/types" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +// --- mock fork backend / launcher ---------------------------------------------------------------- + +// mockForkBackend is a programmable forkBackend. Each method delegates to its function field when +// set; otherwise a cooperative default is used: SendBridgeAssetTx assigns the next deposit count and +// returns a deterministic hash, and WaitForReceipt returns a BridgeEvent receipt for that hash. This +// lets the happy path run with zero configuration while individual tests override single methods to +// inject errors, reverts or timeouts. +type mockForkBackend struct { + localExitRoot func(ctx context.Context, blockTag string) (common.Hash, error) + tokenWrapped func(ctx context.Context, net uint32, addr common.Address) (common.Address, error) + tokenMeta func(ctx context.Context, l2Token common.Address) ([]byte, error) + gasTokenMeta func(ctx context.Context) ([]byte, error) + setBalance func(ctx context.Context, sender common.Address) error + prepareERC20 func(ctx context.Context, sender, token common.Address) error + sendTx func(ctx context.Context, e *agglayertypes.BridgeExit, isNative bool, token common.Address) (common.Hash, error) + waitReceipt func(ctx context.Context, hash common.Hash) ([]rpcLog, error) + + mu sync.Mutex + nextDeposit uint32 + hashToDeposit map[common.Hash]uint32 + setBalanceCalls []common.Address + prepareCalls []common.Address +} + +func newMockBackend() *mockForkBackend { + return &mockForkBackend{hashToDeposit: map[common.Hash]uint32{}} +} + +func (m *mockForkBackend) LocalExitRoot(ctx context.Context, blockTag string) (common.Hash, error) { + if m.localExitRoot != nil { + return m.localExitRoot(ctx, blockTag) + } + return common.Hash{}, nil +} + +func (m *mockForkBackend) TokenWrappedAddress( + ctx context.Context, net uint32, addr common.Address, +) (common.Address, error) { + if m.tokenWrapped != nil { + return m.tokenWrapped(ctx, net, addr) + } + return common.Address{}, nil +} + +func (m *mockForkBackend) TokenMetadata(ctx context.Context, l2Token common.Address) ([]byte, error) { + if m.tokenMeta != nil { + return m.tokenMeta(ctx, l2Token) + } + return nil, nil +} + +func (m *mockForkBackend) GasTokenMetadata(ctx context.Context) ([]byte, error) { + if m.gasTokenMeta != nil { + return m.gasTokenMeta(ctx) + } + return nil, nil +} + +func (m *mockForkBackend) SetSenderBalance(ctx context.Context, sender common.Address) error { + m.mu.Lock() + m.setBalanceCalls = append(m.setBalanceCalls, sender) + m.mu.Unlock() + if m.setBalance != nil { + return m.setBalance(ctx, sender) + } + return nil +} + +func (m *mockForkBackend) PrepareERC20Token(ctx context.Context, sender, token common.Address) error { + m.mu.Lock() + m.prepareCalls = append(m.prepareCalls, token) + m.mu.Unlock() + if m.prepareERC20 != nil { + return m.prepareERC20(ctx, sender, token) + } + return nil +} + +func (m *mockForkBackend) SendBridgeAssetTx( + ctx context.Context, e *agglayertypes.BridgeExit, isNative bool, token common.Address, +) (common.Hash, error) { + if m.sendTx != nil { + return m.sendTx(ctx, e, isNative, token) + } + // default: assign the next deposit count and a deterministic hash for it + m.mu.Lock() + defer m.mu.Unlock() + dc := m.nextDeposit + m.nextDeposit++ + hash := common.BigToHash(big.NewInt(int64(dc) + 1)) + m.hashToDeposit[hash] = dc + return hash, nil +} + +func (m *mockForkBackend) WaitForReceipt(ctx context.Context, hash common.Hash) ([]rpcLog, error) { + if m.waitReceipt != nil { + return m.waitReceipt(ctx, hash) + } + m.mu.Lock() + dc := m.hashToDeposit[hash] + m.mu.Unlock() + return bridgeEventReceipt(dc, uint64(dc)+1, uint64(dc)), nil +} + +type mockForkLauncher struct { + backend forkBackend + err error + started bool +} + +func (l *mockForkLauncher) Start( + _ context.Context, _ string, _ uint64, _ common.Address, +) (forkBackend, func(), error) { + if l.err != nil { + return nil, nil, l.err + } + l.started = true + return l.backend, func() {}, nil +} + +// bridgeEventReceipt builds a receipt carrying a single BridgeEvent log with the given deposit count. +func bridgeEventReceipt(depositCount uint32, blockNum, logIndex uint64) []rpcLog { + data, err := bridgeABI.Events["BridgeEvent"].Inputs.Pack( + uint8(0), uint32(0), common.Address{}, uint32(0), common.Address{}, big.NewInt(0), []byte{}, depositCount, + ) + if err != nil { + panic(err) + } + return []rpcLog{{ + Topics: []string{bridgeEventTopicHash.Hex()}, + Data: "0x" + common.Bytes2Hex(data), + BlockNumber: fmt.Sprintf("0x%x", blockNum), + LogIndex: fmt.Sprintf("0x%x", logIndex), + }} +} + +func replayTestConfig(t *testing.T) *Config { + t.Helper() + return &Config{Options: Options{ + OutputDir: t.TempDir(), + ConcurrencyLimit: 2, + VerifyNewLocalExitRootUsingShadowFork: true, // these tests exercise the shadow-fork orchestration + }} +} + +// nativeAssetExit builds a native (gas-token) exit the way step_d does: a non-nil TokenInfo with a +// zero origin address. The replay path dereferences BridgeExit.TokenInfo, so production never carries +// a nil one — this mirrors that shape (unlike the order-test nativeExit helper, which uses nil). +func nativeAssetExit(dest common.Address, amount int64) *agglayertypes.BridgeExit { + return &agglayertypes.BridgeExit{ + TokenInfo: &agglayertypes.TokenInfo{}, + DestinationNetwork: 0, + DestinationAddress: dest, + Amount: big.NewInt(amount), + } +} + +func TestVerifyReplayMetadata(t *testing.T) { + t.Parallel() + + exits := []*agglayertypes.BridgeExit{ + {DestinationAddress: common.HexToAddress("0x1"), Metadata: nil}, + {DestinationAddress: common.HexToAddress("0x2"), Metadata: []byte{0xab, 0xcd}}, + } + + // nil and an empty slice compare equal; matching metadata passes. + require.NoError(t, verifyReplayMetadata(exits, [][]byte{{}, {0xab, 0xcd}})) + + // a per-exit divergence names the offending exit and both metadata blobs. + err := verifyReplayMetadata(exits, [][]byte{{}, {0xab, 0xff}}) + require.Error(t, err) + require.Contains(t, err.Error(), "bridge exit 1") + require.Contains(t, err.Error(), "abcd") + require.Contains(t, err.Error(), "abff") +} + +// --- resolveTokenAddresses ----------------------------------------------------------------------- + +func TestResolveTokenAddresses(t *testing.T) { + t.Parallel() + const l2NetworkID = uint32(10) + gasNet, gasAddr := uint32(0), common.Address{} + + l2NativeTok := common.BytesToAddress([]byte("l2native")) + lbtOrigin := common.BytesToAddress([]byte("lbtOrigin")) + lbtWrapped := common.BytesToAddress([]byte("lbtWrapped")) + contractOrigin := common.BytesToAddress([]byte("contractOrigin")) + contractWrapped := common.BytesToAddress([]byte("contractWrapped")) + + exits := []*agglayertypes.BridgeExit{ + nativeAssetExit(common.BytesToAddress([]byte("dest0")), 1), // native → skipped + erc20Exit(l2NetworkID, l2NativeTok, common.HexToAddress("0xd1"), 2), // L2-native → maps to self + erc20Exit(1, lbtOrigin, common.HexToAddress("0xd2"), 3), // resolved from LBT + erc20Exit(1, contractOrigin, common.HexToAddress("0xd3"), 4), // resolved from contract + } + lbtMap := map[tokenOriginKey]common.Address{{network: 1, addr: lbtOrigin}: lbtWrapped} + + backend := newMockBackend() + var wrappedCalls int + backend.tokenWrapped = func(_ context.Context, net uint32, addr common.Address) (common.Address, error) { + wrappedCalls++ + require.Equal(t, uint32(1), net) + require.Equal(t, contractOrigin, addr) + return contractWrapped, nil + } + + got, err := resolveTokenAddresses(context.Background(), backend, exits, l2NetworkID, gasNet, gasAddr, lbtMap) + require.NoError(t, err) + require.Equal(t, l2NativeTok, got[tokenOriginKey{l2NetworkID, l2NativeTok}]) + require.Equal(t, lbtWrapped, got[tokenOriginKey{1, lbtOrigin}]) + require.Equal(t, contractWrapped, got[tokenOriginKey{1, contractOrigin}]) + require.Equal(t, 1, wrappedCalls, "only the non-LBT external token hits the contract") +} + +func TestResolveTokenAddressesContractErrors(t *testing.T) { + t.Parallel() + origin := common.BytesToAddress([]byte("origin")) + exits := []*agglayertypes.BridgeExit{erc20Exit(1, origin, common.HexToAddress("0xd"), 1)} + + // zero wrapped address → error + backend := newMockBackend() + backend.tokenWrapped = func(context.Context, uint32, common.Address) (common.Address, error) { + return common.Address{}, nil + } + _, err := resolveTokenAddresses(context.Background(), backend, exits, 10, 0, common.Address{}, nil) + require.Error(t, err) + + // contract call fails → error + backend.tokenWrapped = func(context.Context, uint32, common.Address) (common.Address, error) { + return common.Address{}, errors.New("rpc down") + } + _, err = resolveTokenAddresses(context.Background(), backend, exits, 10, 0, common.Address{}, nil) + require.Error(t, err) +} + +// --- replayBridgeExits --------------------------------------------------------------------------- + +func TestReplayBridgeExitsHappyPath(t *testing.T) { + t.Parallel() + exits := []*agglayertypes.BridgeExit{ + nativeAssetExit(common.HexToAddress("0x01"), 10), + nativeAssetExit(common.HexToAddress("0x02"), 20), + nativeAssetExit(common.HexToAddress("0x03"), 30), + } + backend := newMockBackend() + + leaves, err := replayBridgeExits(context.Background(), replayTestConfig(t), backend, + exits, nil, 0, common.Address{}) + require.NoError(t, err) + require.Len(t, leaves, len(exits)) + + seen := map[uint32]bool{} + for _, l := range leaves { + require.NotEqual(t, common.Hash{}, l.TxHash) + seen[l.DepositCount] = true + } + require.Len(t, seen, len(exits), "each exit got a distinct deposit count") + require.Len(t, backend.setBalanceCalls, 3, "balance set once per sender") +} + +func TestReplayBridgeExitsERC20Prepares(t *testing.T) { + t.Parallel() + token := common.BytesToAddress([]byte("tok")) + exits := []*agglayertypes.BridgeExit{erc20Exit(1, common.BytesToAddress([]byte("orig")), common.HexToAddress("0x01"), 5)} + l2Tokens := map[tokenOriginKey]common.Address{{network: 1, addr: common.BytesToAddress([]byte("orig"))}: token} + + backend := newMockBackend() + leaves, err := replayBridgeExits(context.Background(), replayTestConfig(t), backend, + exits, l2Tokens, 0, common.Address{}) + require.NoError(t, err) + require.Len(t, leaves, 1) + require.Equal(t, []common.Address{token}, backend.prepareCalls) +} + +func TestReplayBridgeExitsUnresolvedTokenFails(t *testing.T) { + t.Parallel() + // ERC-20 exit whose token is absent from the resolved map → findTokenAddress fails up front. + exits := []*agglayertypes.BridgeExit{erc20Exit(1, common.BytesToAddress([]byte("orig")), common.HexToAddress("0x01"), 5)} + _, err := replayBridgeExits(context.Background(), replayTestConfig(t), newMockBackend(), + exits, nil, 0, common.Address{}) + require.Error(t, err) +} + +func TestReplayBridgeExitsSendErrorFailsFast(t *testing.T) { + t.Parallel() + exits := []*agglayertypes.BridgeExit{nativeAssetExit(common.HexToAddress("0x01"), 1)} + backend := newMockBackend() + backend.sendTx = func(context.Context, *agglayertypes.BridgeExit, bool, common.Address) (common.Hash, error) { + return common.Hash{}, errors.New("send failed") + } + _, err := replayBridgeExits(context.Background(), replayTestConfig(t), backend, + exits, nil, 0, common.Address{}) + require.Error(t, err) + require.Contains(t, err.Error(), "send failed") +} + +func TestReplayBridgeExitsRevertFailsFast(t *testing.T) { + t.Parallel() + exits := []*agglayertypes.BridgeExit{nativeAssetExit(common.HexToAddress("0x01"), 1)} + backend := newMockBackend() + backend.waitReceipt = func(context.Context, common.Hash) ([]rpcLog, error) { + return nil, errors.New("transaction reverted") + } + _, err := replayBridgeExits(context.Background(), replayTestConfig(t), backend, + exits, nil, 0, common.Address{}) + require.Error(t, err) +} + +func TestReplayBridgeExitsMissingBridgeEventFailsFast(t *testing.T) { + t.Parallel() + exits := []*agglayertypes.BridgeExit{nativeAssetExit(common.HexToAddress("0x01"), 1)} + backend := newMockBackend() + backend.waitReceipt = func(context.Context, common.Hash) ([]rpcLog, error) { + return []rpcLog{{Topics: []string{common.HexToHash("0xunrelated").Hex()}}}, nil + } + _, err := replayBridgeExits(context.Background(), replayTestConfig(t), backend, + exits, nil, 0, common.Address{}) + require.Error(t, err) +} + +func TestReplayBridgeExitsDeferredThenRetried(t *testing.T) { + t.Parallel() + exits := []*agglayertypes.BridgeExit{nativeAssetExit(common.HexToAddress("0x01"), 1)} + backend := newMockBackend() + + var mu sync.Mutex + calls := map[common.Hash]int{} + backend.waitReceipt = func(_ context.Context, hash common.Hash) ([]rpcLog, error) { + mu.Lock() + calls[hash]++ + n := calls[hash] + mu.Unlock() + if n == 1 { + return nil, errReceiptTimeout // first poll times out → deferred + } + return bridgeEventReceipt(0, 1, 0), nil // retry pass finds the receipt + } + + leaves, err := replayBridgeExits(context.Background(), replayTestConfig(t), backend, + exits, nil, 0, common.Address{}) + require.NoError(t, err) + require.Len(t, leaves, 1) + require.NotEqual(t, common.Hash{}, leaves[0].TxHash) +} + +// --- retryDeferredExit --------------------------------------------------------------------------- + +func newSentTx() sentTx { + exit := nativeAssetExit(common.HexToAddress("0x01"), 1) + return sentTx{ + index: 0, + hash: common.HexToHash("0xaaa"), + job: exitJob{index: 0, bridge: exit, isNative: true}, + } +} + +func TestRetryDeferredExitImmediate(t *testing.T) { + t.Parallel() + backend := newMockBackend() + backend.waitReceipt = func(context.Context, common.Hash) ([]rpcLog, error) { + return bridgeEventReceipt(7, 5, 1), nil + } + leaf, err := retryDeferredExit(context.Background(), backend, newSentTx()) + require.NoError(t, err) + require.Equal(t, uint32(7), leaf.DepositCount) +} + +func TestRetryDeferredExitResendThenSucceeds(t *testing.T) { + t.Parallel() + backend := newMockBackend() + var polls int + backend.waitReceipt = func(context.Context, common.Hash) ([]rpcLog, error) { + polls++ + if polls == 1 { + return nil, errReceiptTimeout // still not mined → triggers a re-send + } + return bridgeEventReceipt(3, 2, 0), nil + } + var resent bool + backend.sendTx = func(context.Context, *agglayertypes.BridgeExit, bool, common.Address) (common.Hash, error) { + resent = true + return common.HexToHash("0xbbb"), nil + } + leaf, err := retryDeferredExit(context.Background(), backend, newSentTx()) + require.NoError(t, err) + require.Equal(t, uint32(3), leaf.DepositCount) + require.True(t, resent) +} + +func TestRetryDeferredExitRevertIsTerminal(t *testing.T) { + t.Parallel() + backend := newMockBackend() + backend.waitReceipt = func(context.Context, common.Hash) ([]rpcLog, error) { + return nil, errors.New("transaction reverted") + } + _, err := retryDeferredExit(context.Background(), backend, newSentTx()) + require.Error(t, err) +} + +func TestRetryDeferredExitResendError(t *testing.T) { + t.Parallel() + backend := newMockBackend() + backend.waitReceipt = func(context.Context, common.Hash) ([]rpcLog, error) { + return nil, errReceiptTimeout + } + backend.sendTx = func(context.Context, *agglayertypes.BridgeExit, bool, common.Address) (common.Hash, error) { + return common.Hash{}, errors.New("resend failed") + } + _, err := retryDeferredExit(context.Background(), backend, newSentTx()) + require.Error(t, err) + require.Contains(t, err.Error(), "re-send") +} + +// --- runStepG2ShadowFork via mock launcher ------------------------------------------------------- + +func TestRunStepG2ShadowForkLauncherError(t *testing.T) { + t.Parallel() + cfg := replayTestConfig(t) + launcher := &mockForkLauncher{err: errors.New("anvil not found")} + _, err := runStepG2(context.Background(), cfg, launcher, 100, + &agglayertypes.Certificate{BridgeExits: []*agglayertypes.BridgeExit{nativeAssetExit(common.HexToAddress("0x1"), 1)}}, nil) + require.Error(t, err) +} + +func TestRunStepG2ShadowForkRootMismatchAborts(t *testing.T) { + t.Parallel() + cfg := replayTestConfig(t) + makeG1LiteDB(t, cfg, 2) + + backend := newMockBackend() + backend.nextDeposit = 2 // replayed deposit counts continue after the 2 genesis→fork bridges + backend.localExitRoot = func(context.Context, string) (common.Hash, error) { + return common.HexToHash("0xdeadbeef"), nil // never matches the rebuilt lite tree + } + launcher := &mockForkLauncher{backend: backend} + + cert := &agglayertypes.Certificate{BridgeExits: []*agglayertypes.BridgeExit{ + nativeAssetExit(common.HexToAddress("0x01"), 100), + }} + _, err := runStepG2(context.Background(), cfg, launcher, 100, cert, nil) + require.Error(t, err) + require.Contains(t, err.Error(), "does not match contract getRoot") + require.True(t, launcher.started) +} + +// TestRunStepG2ShadowForkRootMismatchAbortsEvenWithIgnoreOption verifies that a root mismatch is a +// hard error regardless of ignoreUnsupportedL2Events: that option only affects Step G1's lite syncer, +// not the Step G2 root cross-check against the contract. +func TestRunStepG2ShadowForkRootMismatchAbortsEvenWithIgnoreOption(t *testing.T) { + t.Parallel() + cfg := replayTestConfig(t) + cfg.Options.IgnoreUnsupportedL2Events = true + makeG1LiteDB(t, cfg, 2) + + backend := newMockBackend() + backend.nextDeposit = 2 // replayed deposit counts continue after the 2 genesis→fork bridges + backend.localExitRoot = func(context.Context, string) (common.Hash, error) { + return common.HexToHash("0xdeadbeef"), nil + } + launcher := &mockForkLauncher{backend: backend} + + cert := &agglayertypes.Certificate{BridgeExits: []*agglayertypes.BridgeExit{ + nativeAssetExit(common.HexToAddress("0x01"), 100), + nativeAssetExit(common.HexToAddress("0x02"), 200), + }} + _, err := runStepG2(context.Background(), cfg, launcher, 100, cert, nil) + require.Error(t, err) + require.Contains(t, err.Error(), "does not match contract getRoot") +} diff --git a/tools/exit_certificate/step_g2_rpc_test.go b/tools/exit_certificate/step_g2_rpc_test.go new file mode 100644 index 000000000..538e0affd --- /dev/null +++ b/tools/exit_certificate/step_g2_rpc_test.go @@ -0,0 +1,424 @@ +package exit_certificate + +import ( + "context" + "encoding/hex" + "encoding/json" + "math/big" + "net/http" + "net/http/httptest" + "strings" + "testing" + + agglayertypes "github.com/agglayer/aggkit/agglayer/types" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +// rpcResponder stubs a JSON-RPC method call: it returns either a result or an RPC-level error for the +// given method/params. A nil result with a nil error responds with a JSON null result. +type rpcResponder func(method string, params []any) (json.RawMessage, *jsonRPCError) + +// newRPCStub starts an httptest server that decodes each single JSON-RPC request and dispatches it to +// respond. It is closed automatically when the test ends. +func newRPCStub(t *testing.T, respond rpcResponder) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req jsonRPCRequest + require.NoError(t, json.NewDecoder(r.Body).Decode(&req)) + params, _ := req.Params.([]any) + result, rpcErr := respond(req.Method, params) + resp := jsonRPCResponse{JSONRPC: "2.0", ID: req.ID, Result: result, Error: rpcErr} + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + })) + t.Cleanup(srv.Close) + return srv +} + +// hexResult wraps raw ABI-encoded bytes as the quoted hex string an eth_call result uses. +func hexResult(b []byte) json.RawMessage { + return json.RawMessage(`"0x` + hex.EncodeToString(b) + `"`) +} + +// quoted wraps s as a JSON string result (e.g. a tx hash returned by eth_sendTransaction). +func quoted(s string) json.RawMessage { + return json.RawMessage(`"` + s + `"`) +} + +func TestSetSenderBalance(t *testing.T) { + t.Parallel() + sender := common.HexToAddress("0x1111111111111111111111111111111111111111") + + t.Run("success", func(t *testing.T) { + t.Parallel() + srv := newRPCStub(t, func(method string, params []any) (json.RawMessage, *jsonRPCError) { + require.Equal(t, "anvil_setBalance", method) + require.Equal(t, sender.Hex(), params[0]) + require.Equal(t, largeETHBalance, params[1]) + return quoted("0x1"), nil + }) + require.NoError(t, setSenderBalance(context.Background(), srv.URL, sender)) + }) + + t.Run("rpc error", func(t *testing.T) { + t.Parallel() + srv := newRPCStub(t, func(string, []any) (json.RawMessage, *jsonRPCError) { + return nil, revertErr() + }) + err := setSenderBalance(context.Background(), srv.URL, sender) + require.Error(t, err) + require.Contains(t, err.Error(), "set balance") + }) +} + +func TestReadLocalExitRoot(t *testing.T) { + t.Parallel() + bridge := common.HexToAddress("0x2222222222222222222222222222222222222222") + want := common.HexToHash("0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef0") + + t.Run("success", func(t *testing.T) { + t.Parallel() + out, err := bridgeABI.Methods["getRoot"].Outputs.Pack([32]byte(want)) + require.NoError(t, err) + srv := newRPCStub(t, func(method string, params []any) (json.RawMessage, *jsonRPCError) { + require.Equal(t, rpcMethodEthCall, method) + call, ok := params[0].(map[string]any) + require.True(t, ok) + require.Equal(t, bridge.Hex(), call["to"]) + return hexResult(out), nil + }) + got, err := readLocalExitRoot(context.Background(), srv.URL, bridge, "latest") + require.NoError(t, err) + require.Equal(t, want, got) + }) + + t.Run("rpc error", func(t *testing.T) { + t.Parallel() + srv := newRPCStub(t, func(string, []any) (json.RawMessage, *jsonRPCError) { + return nil, revertErr() + }) + _, err := readLocalExitRoot(context.Background(), srv.URL, bridge, "latest") + require.Error(t, err) + }) + + // LocalExitRoot wrapper on the production backend exercises the same path. + t.Run("backend wrapper", func(t *testing.T) { + t.Parallel() + out, err := bridgeABI.Methods["getRoot"].Outputs.Pack([32]byte(want)) + require.NoError(t, err) + srv := newRPCStub(t, func(string, []any) (json.RawMessage, *jsonRPCError) { + return hexResult(out), nil + }) + backend := &anvilForkBackend{url: srv.URL, bridgeAddr: bridge} + got, err := backend.LocalExitRoot(context.Background(), "latest") + require.NoError(t, err) + require.Equal(t, want, got) + }) +} + +func TestCallGetTokenWrappedAddress(t *testing.T) { + t.Parallel() + bridge := common.HexToAddress("0x3333333333333333333333333333333333333333") + origin := common.HexToAddress("0x4444444444444444444444444444444444444444") + want := common.HexToAddress("0x5555555555555555555555555555555555555555") + + t.Run("success via backend wrapper", func(t *testing.T) { + t.Parallel() + out, err := bridgeABI.Methods["getTokenWrappedAddress"].Outputs.Pack(want) + require.NoError(t, err) + srv := newRPCStub(t, func(method string, _ []any) (json.RawMessage, *jsonRPCError) { + require.Equal(t, rpcMethodEthCall, method) + return hexResult(out), nil + }) + backend := &anvilForkBackend{url: srv.URL, bridgeAddr: bridge} + got, err := backend.TokenWrappedAddress(context.Background(), 0, origin) + require.NoError(t, err) + require.Equal(t, want, got) + }) + + t.Run("rpc error", func(t *testing.T) { + t.Parallel() + srv := newRPCStub(t, func(string, []any) (json.RawMessage, *jsonRPCError) { + return nil, revertErr() + }) + _, err := callGetTokenWrappedAddress(context.Background(), srv.URL, bridge, 0, origin) + require.Error(t, err) + }) + + t.Run("invalid hex result", func(t *testing.T) { + t.Parallel() + srv := newRPCStub(t, func(string, []any) (json.RawMessage, *jsonRPCError) { + return quoted("0xZZZZ"), nil + }) + _, err := callGetTokenWrappedAddress(context.Background(), srv.URL, bridge, 0, origin) + require.Error(t, err) + require.Contains(t, err.Error(), "decode hex result") + }) +} + +func TestSendAnvilTransaction(t *testing.T) { + t.Parallel() + from := common.HexToAddress("0x6666666666666666666666666666666666666666") + to := common.HexToAddress("0x7777777777777777777777777777777777777777") + wantHash := "0x" + strings.Repeat("ab", 32) + + srv := newRPCStub(t, func(method string, params []any) (json.RawMessage, *jsonRPCError) { + require.Equal(t, "eth_sendTransaction", method) + tx, ok := params[0].(map[string]any) + require.True(t, ok) + require.Equal(t, from.Hex(), tx["from"]) + require.Equal(t, to.Hex(), tx["to"]) + require.Equal(t, anvilTxGasLimit, tx["gas"]) + require.Equal(t, "0x64", tx["value"]) // 100 in hex + return quoted(wantHash), nil + }) + + got, err := sendAnvilTransaction(context.Background(), srv.URL, from, to, big.NewInt(100), []byte{0x01}) + require.NoError(t, err) + require.Equal(t, common.HexToHash(wantHash), got) +} + +// TestAnvilForkBackendWrappers drives the remaining anvilForkBackend methods through stub servers so +// the thin delegation wrappers are exercised (the underlying functions are covered individually). +func TestAnvilForkBackendWrappers(t *testing.T) { + t.Parallel() + bridge := common.HexToAddress("0x1212121212121212121212121212121212121212") + sender := common.HexToAddress("0x3434343434343434343434343434343434343434") + token := common.HexToAddress("0x5656565656565656565656565656565656565656") + maxHex := strings.Repeat("f", 64) + txHash := "0x" + strings.Repeat("78", 32) + + srv := newRPCStub(t, func(method string, _ []any) (json.RawMessage, *jsonRPCError) { + switch method { + case rpcMethodEthCall: // balanceOf for PrepareERC20Token (already funded) + return quoted("0x" + maxHex), nil + case "eth_getTransactionReceipt": + return json.RawMessage(`{"status":"0x1","blockNumber":"0x1","logs":[]}`), nil + default: // anvil_setBalance, eth_sendTransaction + return quoted(txHash), nil + } + }) + backend := &anvilForkBackend{url: srv.URL, bridgeAddr: bridge} + ctx := context.Background() + + require.NoError(t, backend.SetSenderBalance(ctx, sender)) + require.NoError(t, backend.PrepareERC20Token(ctx, sender, token)) + + exit := &agglayertypes.BridgeExit{DestinationNetwork: 1, DestinationAddress: sender, Amount: big.NewInt(1)} + gotHash, err := backend.SendBridgeAssetTx(ctx, exit, false, token) + require.NoError(t, err) + require.Equal(t, common.HexToHash(txHash), gotHash) + + logs, err := backend.WaitForReceipt(ctx, common.HexToHash(txHash)) + require.NoError(t, err) + require.Empty(t, logs) +} + +func TestSendBridgeAssetTx(t *testing.T) { + t.Parallel() + bridge := common.HexToAddress("0x8888888888888888888888888888888888888888") + dest := common.HexToAddress("0x9999999999999999999999999999999999999999") + token := common.HexToAddress("0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + wantHash := "0x" + strings.Repeat("cd", 32) + + exit := &agglayertypes.BridgeExit{ + DestinationNetwork: 1, + DestinationAddress: dest, + Amount: big.NewInt(500), + } + + t.Run("native sets value", func(t *testing.T) { + t.Parallel() + srv := newRPCStub(t, func(_ string, params []any) (json.RawMessage, *jsonRPCError) { + tx, ok := params[0].(map[string]any) + require.True(t, ok) + require.Equal(t, dest.Hex(), tx["from"]) + require.Equal(t, bridge.Hex(), tx["to"]) + require.Equal(t, "0x1f4", tx["value"]) // native exit forwards 500 + return quoted(wantHash), nil + }) + got, err := sendBridgeAssetTx(context.Background(), srv.URL, bridge, exit, true, token) + require.NoError(t, err) + require.Equal(t, common.HexToHash(wantHash), got) + }) + + t.Run("erc20 leaves value unset", func(t *testing.T) { + t.Parallel() + srv := newRPCStub(t, func(_ string, params []any) (json.RawMessage, *jsonRPCError) { + tx, ok := params[0].(map[string]any) + require.True(t, ok) + _, hasValue := tx["value"] + require.False(t, hasValue) // non-native: no ETH value attached + return quoted(wantHash), nil + }) + got, err := sendBridgeAssetTx(context.Background(), srv.URL, bridge, exit, false, token) + require.NoError(t, err) + require.Equal(t, common.HexToHash(wantHash), got) + }) +} + +func TestWaitForReceipt(t *testing.T) { + t.Parallel() + txHash := common.HexToHash("0x" + strings.Repeat("ef", 32)) + + t.Run("null then success returns logs", func(t *testing.T) { + t.Parallel() + calls := 0 + srv := newRPCStub(t, func(method string, _ []any) (json.RawMessage, *jsonRPCError) { + require.Equal(t, "eth_getTransactionReceipt", method) + calls++ + if calls == 1 { + return json.RawMessage("null"), nil + } + return json.RawMessage(`{"status":"0x1","blockNumber":"0x5","logs":[]}`), nil + }) + logs, err := waitForReceipt(context.Background(), srv.URL, txHash) + require.NoError(t, err) + require.Empty(t, logs) + require.GreaterOrEqual(t, calls, 2) + }) + + t.Run("revert reports reason", func(t *testing.T) { + t.Parallel() + srv := newRPCStub(t, func(method string, _ []any) (json.RawMessage, *jsonRPCError) { + switch method { + case "eth_getTransactionReceipt": + return json.RawMessage(`{"status":"0x0","blockNumber":"0x5","logs":[]}`), nil + case "eth_getTransactionByHash": + return json.RawMessage(`{"from":"0x01","to":"0x02","input":"0x","value":"0x0"}`), nil + case rpcMethodEthCall: + return nil, nil // call succeeds → "no revert reason available" + default: + return quoted("0x1"), nil + } + }) + _, err := waitForReceipt(context.Background(), srv.URL, txHash) + require.Error(t, err) + require.Contains(t, err.Error(), "reverted") + }) + + t.Run("context cancelled while polling null", func(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithCancel(context.Background()) + srv := newRPCStub(t, func(string, []any) (json.RawMessage, *jsonRPCError) { + cancel() + return json.RawMessage("null"), nil + }) + _, err := waitForReceipt(ctx, srv.URL, txHash) + require.ErrorIs(t, err, context.Canceled) + }) +} + +func TestFetchRevertReason(t *testing.T) { + t.Parallel() + txHash := common.HexToHash("0x" + strings.Repeat("12", 32)) + + t.Run("call succeeds means no reason", func(t *testing.T) { + t.Parallel() + srv := newRPCStub(t, func(method string, _ []any) (json.RawMessage, *jsonRPCError) { + if method == "eth_getTransactionByHash" { + return json.RawMessage(`{"from":"0x01","to":"0x02","input":"0x","value":"0x0"}`), nil + } + return quoted("0x"), nil + }) + require.Equal(t, "no revert reason available", + fetchRevertReason(context.Background(), srv.URL, txHash, "0x5")) + }) + + t.Run("tx fetch error is reported", func(t *testing.T) { + t.Parallel() + srv := newRPCStub(t, func(string, []any) (json.RawMessage, *jsonRPCError) { + return nil, revertErr() + }) + got := fetchRevertReason(context.Background(), srv.URL, txHash, "") + require.Contains(t, got, "could not fetch tx") + }) +} + +func TestEnsureERC20Balance(t *testing.T) { + t.Parallel() + token := common.HexToAddress("0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb") + account := common.HexToAddress("0xcccccccccccccccccccccccccccccccccccccccc") + maxHex := strings.Repeat("f", 64) // == maxUint256 + + t.Run("sufficient balance skips patch", func(t *testing.T) { + t.Parallel() + srv := newRPCStub(t, func(method string, _ []any) (json.RawMessage, *jsonRPCError) { + require.Equal(t, rpcMethodEthCall, method) // never patches + return quoted("0x" + maxHex), nil + }) + require.NoError(t, ensureERC20Balance(context.Background(), srv.URL, token, account, maxUint256)) + }) + + t.Run("patches first slot then verifies", func(t *testing.T) { + t.Parallel() + patched := false + srv := newRPCStub(t, func(method string, _ []any) (json.RawMessage, *jsonRPCError) { + switch method { + case rpcMethodEthCall: + if patched { + return quoted("0x" + maxHex), nil + } + return quoted("0x0"), nil + case "hardhat_setStorageAt": + patched = true + return quoted("0x1"), nil + default: + return quoted("0x1"), nil + } + }) + require.NoError(t, ensureERC20Balance(context.Background(), srv.URL, token, account, maxUint256)) + require.True(t, patched) + }) + + t.Run("no layout matches errors", func(t *testing.T) { + t.Parallel() + srv := newRPCStub(t, func(method string, _ []any) (json.RawMessage, *jsonRPCError) { + if method == rpcMethodEthCall { + return quoted("0x0"), nil // balance never reaches required + } + return quoted("0x1"), nil + }) + err := ensureERC20Balance(context.Background(), srv.URL, token, account, maxUint256) + require.Error(t, err) + require.Contains(t, err.Error(), "no storage layout matched") + }) +} + +func TestPrepareERC20Token(t *testing.T) { + t.Parallel() + bridge := common.HexToAddress("0xdddddddddddddddddddddddddddddddddddddddd") + sender := common.HexToAddress("0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee") + token := common.HexToAddress("0xffffffffffffffffffffffffffffffffffffffff") + maxHex := strings.Repeat("f", 64) + + t.Run("zero token address errors", func(t *testing.T) { + t.Parallel() + err := prepareERC20Token(context.Background(), "http://unused", bridge, sender, common.Address{}) + require.Error(t, err) + require.Contains(t, err.Error(), "invalid L2 token address") + }) + + t.Run("balance sufficient then approves", func(t *testing.T) { + t.Parallel() + sentApprove := false + srv := newRPCStub(t, func(method string, params []any) (json.RawMessage, *jsonRPCError) { + switch method { + case rpcMethodEthCall: + return quoted("0x" + maxHex), nil // already funded + case "eth_sendTransaction": + tx, ok := params[0].(map[string]any) + require.True(t, ok) + require.Equal(t, sender.Hex(), tx["from"]) + require.Equal(t, token.Hex(), tx["to"]) // approve goes to the token + sentApprove = true + return quoted("0x" + strings.Repeat("01", 32)), nil + default: + return quoted("0x1"), nil + } + }) + require.NoError(t, prepareERC20Token(context.Background(), srv.URL, bridge, sender, token)) + require.True(t, sentApprove) + }) +} diff --git a/tools/exit_certificate/step_g_events.go b/tools/exit_certificate/step_g_events.go new file mode 100644 index 000000000..0ed63a4ef --- /dev/null +++ b/tools/exit_certificate/step_g_events.go @@ -0,0 +1,153 @@ +package exit_certificate + +import ( + "context" + "fmt" + + agglayertypes "github.com/agglayer/aggkit/agglayer/types" + "github.com/agglayer/aggkit/log" + "github.com/agglayer/aggkit/tools/exit_certificate/bridgesyncerlite" + "github.com/ethereum/go-ethereum/common" +) + +// buildLiteTreeFromCertificate builds the local exit tree by inserting the certificate's bridge +// exits into the lite DB — in their given order, continuing the deposit counts after Step G1's +// genesis→fork bridges — and returns the tree root and the per-exit metadata used for each leaf, in +// the same order. This is the single way the local exit tree (the sqlite the claimer later reads for +// proofs) is built: the off-chain path uses it to compute the NewLocalExitRoot, and the shadow-fork +// path uses it to produce the root it cross-checks against the contract's getRoot(). +// +// The leaf encoding mirrors what the bridge contract emits: a native exit (no token info / gas +// token) uses the gas token as origin; an ERC-20 exit uses its TokenInfo origin. Each leaf carries +// the raw generatedMetadata for its exit (generated by generateMetadata, replicating bridgeAsset) — +// the lite syncer keccak256-hashes it for the leaf, exactly as the contract does. If the generated +// metadata is wrong for an exit, the shadow-fork root cross-check (treeRoot vs getRoot) detects it. +// generatedMetadata must be aligned by index with certificate.BridgeExits. +func buildLiteTreeFromCertificate( + ctx context.Context, cfg *Config, certificate *agglayertypes.Certificate, + forkBlock uint64, gasTokenNetwork uint32, gasTokenAddress common.Address, generatedMetadata [][]byte, +) (common.Hash, [][]byte, error) { + exits := certificate.BridgeExits + if len(generatedMetadata) != len(exits) { + return common.Hash{}, nil, fmt.Errorf("generated metadata count %d does not match bridge exit count %d", + len(generatedMetadata), len(exits)) + } + + nextDepositCount, err := liteForkNextDepositCount(ctx, cfg) + if err != nil { + return common.Hash{}, nil, err + } + + leaves := make([]bridgesyncerlite.BridgeLeaf, len(exits)) + metadatas := make([][]byte, len(exits)) + for i, be := range exits { + originNetwork, originAddr := gasTokenNetwork, gasTokenAddress + if be.TokenInfo != nil && be.TokenInfo.OriginTokenAddress != (common.Address{}) { + originNetwork = be.TokenInfo.OriginNetwork + originAddr = be.TokenInfo.OriginTokenAddress + } + leaves[i] = bridgesyncerlite.BridgeLeaf{ + // Synthetic block position: the leaves do not exist on a chain here. BlockNum/BlockPos do + // not affect the tree root (only leaf hash and deposit-count order do); they just need to be + // present and distinct, so we place them all in the block right after the fork. + BlockNum: forkBlock + 1, + BlockPos: uint64(i), + LeafType: uint8(be.LeafType), + OriginNetwork: originNetwork, + OriginAddress: originAddr, + DestinationNetwork: be.DestinationNetwork, + DestinationAddress: be.DestinationAddress, + Amount: be.Amount, + Metadata: generatedMetadata[i], + DepositCount: nextDepositCount + uint32(i), + } + metadatas[i] = generatedMetadata[i] + } + + ler, err := buildLiteTreeWithReplayed(ctx, cfg, leaves) + if err != nil { + return common.Hash{}, nil, err + } + log.Infof("Local exit root from lite tree of %d certificate exits: %s", len(exits), ler.Hex()) + return ler, metadatas, nil +} + +// liteForkNextDepositCount returns the deposit count the first certificate exit should get: one past +// the highest deposit count Step G1 synced into the lite DB (i.e. the number of genesis→fork +// bridges). It opens the G1 lite DB read-only (no chain access). +func liteForkNextDepositCount(ctx context.Context, cfg *Config) (uint32, error) { + dbPath := g1LiteDBPath(cfg) + if !fileExists(dbPath) { + return 0, fmt.Errorf("lite syncer DB %s not found (run Step G1 first)", dbPath) + } + syncer, err := bridgesyncerlite.New(ctx, bridgesyncerlite.Config{DBPath: dbPath}, + log.WithFields("module", "exit-cert-bridgesyncerlite")) + if err != nil { + return 0, fmt.Errorf("open lite syncer DB: %w", err) + } + defer func() { + if cerr := syncer.Close(); cerr != nil { + log.Warnf("error closing lite bridge syncer: %v", cerr) + } + }() + nextDepositCount, err := syncer.NextDepositCount(ctx) + if err != nil { + return 0, fmt.Errorf("read genesis→fork next deposit count: %w", err) + } + return nextDepositCount, nil +} + +// buildLiteTreeWithReplayed builds the full exit tree for Step G2 and returns its root. +// +// Step G1 persisted the L2 bridges from genesis up to the shadow-fork block (no tree yet) into the +// G1 lite DB. Here we copy that DB to the G2 lite DB (so G1's DB stays intact and reusable across G2 +// re-runs) and, on the copy, **insert the replayed bridges directly** — they were already captured +// from the replay's BridgeEvents, so no second pass over Anvil is needed (the syncer runs DB-only, +// it never touches the chain). We then build the whole exit tree once from the full set +// (genesis→fork plus replayed); the resulting root is what RunStepG2 cross-checks against the forked +// contract's getRoot(). +func buildLiteTreeWithReplayed( + ctx context.Context, cfg *Config, replayedLeaves []bridgesyncerlite.BridgeLeaf, +) (common.Hash, error) { + srcPath := g1LiteDBPath(cfg) + if !fileExists(srcPath) { + return common.Hash{}, fmt.Errorf("lite syncer DB %s not found (run Step G1 first)", srcPath) + } + + // Work on a copy so Step G1's DB (genesis→fork bridges) is left untouched and a G2 re-run starts + // from a clean copy rather than a tree already built/appended by a previous run. + dbPath := g2LiteDBPath(cfg) + if err := copyLiteDB(srcPath, dbPath); err != nil { + return common.Hash{}, fmt.Errorf("copy lite syncer DB for Step G2: %w", err) + } + log.Infof("Copied lite syncer DB %s → %s for Step G2", srcPath, dbPath) + + // DB-only syncer (no RPCURL): we only persist the already-collected bridges and build the tree, + // so it must not make any Anvil calls. + syncer, err := bridgesyncerlite.New(ctx, bridgesyncerlite.Config{ + DBPath: dbPath, + }, log.WithFields("module", "exit-cert-bridgesyncerlite")) + if err != nil { + return common.Hash{}, fmt.Errorf("reopen lite syncer DB: %w", err) + } + defer func() { + if cerr := syncer.Close(); cerr != nil { + log.Warnf("error closing lite bridge syncer: %v", cerr) + } + }() + + // Persist the replayed bridges alongside the genesis→fork ones Step G1 stored (StoreBridges sorts + // them by deposit count, so their order here does not matter). + if err := syncer.StoreBridges(ctx, replayedLeaves); err != nil { + return common.Hash{}, fmt.Errorf("store replayed bridges: %w", err) + } + + // Build the whole exit tree once, now that every bridge is persisted. + ler, err := syncer.BuildTree(ctx) + if err != nil { + return common.Hash{}, fmt.Errorf("build lite exit tree: %w", err) + } + log.Infof("Built lite exit tree with %d replayed bridges; local exit root = %s", + len(replayedLeaves), ler.Hex()) + return ler, nil +} diff --git a/tools/exit_certificate/step_g_events_test.go b/tools/exit_certificate/step_g_events_test.go new file mode 100644 index 000000000..fc8bc20eb --- /dev/null +++ b/tools/exit_certificate/step_g_events_test.go @@ -0,0 +1,237 @@ +package exit_certificate + +import ( + "context" + "encoding/json" + "io" + "math/big" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + agglayertypes "github.com/agglayer/aggkit/agglayer/types" + "github.com/agglayer/aggkit/log" + "github.com/agglayer/aggkit/tools/exit_certificate/bridgesyncerlite" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +// newStubRPCServer returns a fast JSON-RPC server that answers the read-only calls the off-chain +// Step G2 path makes (eth_call for getRoot/gas-token, eth_chainId, eth_blockNumber) with zero values, +// so readLocalExitRoot/fetchGasTokenInfo succeed instantly instead of retrying with backoff. +func newStubRPCServer(t *testing.T) string { + t.Helper() + zeroWord := "0x0000000000000000000000000000000000000000000000000000000000000000" + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + var req struct { + ID json.RawMessage `json:"id"` + Method string `json:"method"` + } + _ = json.Unmarshal(body, &req) + var result string + switch req.Method { + case "eth_call": + result = zeroWord + case "eth_chainId", "eth_blockNumber": + result = "0x1" + default: + result = "0x" + } + resp := map[string]any{"jsonrpc": "2.0", "id": req.ID, "result": result} + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + })) + t.Cleanup(srv.Close) + return srv.URL +} + +// makeG1LiteDB creates a Step G1 lite DB in cfg's output dir holding `genesis` bridges with +// contiguous deposit counts 0..genesis-1, leaving the exit tree unbuilt (as Step G1 does). +func makeG1LiteDB(t *testing.T, cfg *Config, genesis int) { + t.Helper() + require.NoError(t, os.MkdirAll(cfg.Options.OutputDir, 0o755)) + syncer, err := bridgesyncerlite.New(context.Background(), + bridgesyncerlite.Config{DBPath: g1LiteDBPath(cfg)}, log.WithFields("module", "test")) + require.NoError(t, err) + defer func() { require.NoError(t, syncer.Close()) }() + + leaves := make([]bridgesyncerlite.BridgeLeaf, genesis) + for i := range genesis { + leaves[i] = bridgesyncerlite.BridgeLeaf{ + BlockNum: uint64(10 + i), + BlockPos: uint64(i), + OriginAddress: common.BytesToAddress([]byte{byte(i + 1)}), + DestinationNetwork: 1, + DestinationAddress: common.BytesToAddress([]byte{byte(i + 100)}), + Amount: big.NewInt(int64(i) * 10), + DepositCount: uint32(i), + } + } + require.NoError(t, syncer.StoreBridges(context.Background(), leaves)) +} + +func testConfig(t *testing.T) *Config { + t.Helper() + return &Config{ + Options: Options{OutputDir: t.TempDir()}, + } +} + +func TestLiteDBPaths(t *testing.T) { + t.Parallel() + cfg := &Config{Options: Options{OutputDir: "/tmp/out"}} + require.Equal(t, filepath.Join("/tmp/out", "step-g1-l2bridgesyncerlite.sqlite"), g1LiteDBPath(cfg)) + require.Equal(t, filepath.Join("/tmp/out", "step-g-l2bridgesyncerlite.sqlite"), g2LiteDBPath(cfg)) +} + +func TestLiteForkNextDepositCount(t *testing.T) { + t.Parallel() + cfg := testConfig(t) + + // missing DB → error + _, err := liteForkNextDepositCount(context.Background(), cfg) + require.Error(t, err) + + makeG1LiteDB(t, cfg, 3) + next, err := liteForkNextDepositCount(context.Background(), cfg) + require.NoError(t, err) + require.Equal(t, uint32(3), next) +} + +func TestBuildLiteTreeWithReplayed(t *testing.T) { + t.Parallel() + cfg := testConfig(t) + + // missing G1 DB → error + _, err := buildLiteTreeWithReplayed(context.Background(), cfg, nil) + require.Error(t, err) + + makeG1LiteDB(t, cfg, 2) + replayed := []bridgesyncerlite.BridgeLeaf{ + {BlockNum: 50, BlockPos: 0, DestinationNetwork: 1, Amount: big.NewInt(5), DepositCount: 2}, + {BlockNum: 50, BlockPos: 1, DestinationNetwork: 1, Amount: big.NewInt(6), DepositCount: 3}, + } + root, err := buildLiteTreeWithReplayed(context.Background(), cfg, replayed) + require.NoError(t, err) + require.NotEqual(t, common.Hash{}, root) + // the G2 working copy must have been created, leaving G1's DB intact + require.FileExists(t, g2LiteDBPath(cfg)) + require.FileExists(t, g1LiteDBPath(cfg)) +} + +func TestBuildLiteTreeFromCertificate(t *testing.T) { + t.Parallel() + cfg := testConfig(t) + makeG1LiteDB(t, cfg, 2) + + gasNet := uint32(0) + gasAddr := common.Address{} + cert := &agglayertypes.Certificate{ + BridgeExits: []*agglayertypes.BridgeExit{ + { // native exit + DestinationNetwork: 1, + DestinationAddress: common.HexToAddress("0xaaa"), + Amount: big.NewInt(100), + Metadata: []byte{0x01}, + }, + { // ERC-20 exit + TokenInfo: &agglayertypes.TokenInfo{OriginNetwork: 1, OriginTokenAddress: common.HexToAddress("0xtok")}, + DestinationNetwork: 1, + DestinationAddress: common.HexToAddress("0xbbb"), + Amount: big.NewInt(200), + Metadata: []byte{0x02, 0x03}, + }, + }, + } + + genMeta := [][]byte{{0x01}, {0x02, 0x03}} + root, metadatas, err := buildLiteTreeFromCertificate(context.Background(), cfg, cert, 1000, gasNet, gasAddr, genMeta) + require.NoError(t, err) + require.NotEqual(t, common.Hash{}, root) + require.Len(t, metadatas, 2) + // the returned metadata is the (raw) generated metadata, used verbatim for the leaf encoding. + require.Equal(t, []byte{0x01}, metadatas[0]) + require.Equal(t, []byte{0x02, 0x03}, metadatas[1]) + + // deterministic: same inputs produce the same root + root2, _, err := buildLiteTreeFromCertificate(context.Background(), cfg, cert, 1000, gasNet, gasAddr, genMeta) + require.NoError(t, err) + require.Equal(t, root, root2) + + // different metadata → different leaves → different root + changedMeta := [][]byte{{0xff}, {0x02, 0x03}} + rootChanged, _, err := buildLiteTreeFromCertificate(context.Background(), cfg, cert, 1000, gasNet, gasAddr, changedMeta) + require.NoError(t, err) + require.NotEqual(t, root, rootChanged) + + // a metadata count mismatch is an error + _, _, err = buildLiteTreeFromCertificate(context.Background(), cfg, cert, 1000, gasNet, gasAddr, [][]byte{{0x01}}) + require.Error(t, err) +} + +func TestRunStepG2NilCertificate(t *testing.T) { + t.Parallel() + _, err := RunStepG2(context.Background(), testConfig(t), 1000, nil, nil) + require.Error(t, err) +} + +func TestRunStepG2EmptyExits(t *testing.T) { + t.Parallel() + cfg := testConfig(t) + cfg.L2RPCURL = newStubRPCServer(t) + res, err := RunStepG2(context.Background(), cfg, 1000, &agglayertypes.Certificate{}, nil) + require.NoError(t, err) + require.Equal(t, uint64(0), res.BridgeExitCount) +} + +func TestRunStepG2LiteOnly(t *testing.T) { + t.Parallel() + cfg := testConfig(t) + cfg.L2RPCURL = newStubRPCServer(t) + cfg.Options.VerifyNewLocalExitRootUsingShadowFork = false // off-chain mode, no Anvil + makeG1LiteDB(t, cfg, 2) + + cert := &agglayertypes.Certificate{ + BridgeExits: []*agglayertypes.BridgeExit{ + {DestinationNetwork: 1, DestinationAddress: common.HexToAddress("0xaaa"), Amount: big.NewInt(1), Metadata: []byte{0x09}}, + }, + } + res, err := RunStepG2(context.Background(), cfg, 1000, cert, nil) + require.NoError(t, err) + require.Equal(t, uint64(1), res.BridgeExitCount) + require.NotEqual(t, common.Hash{}, res.NewLocalExitRoot) + require.Len(t, res.BridgeExitMetadata, 1) +} + +func TestCopyAndRemoveLiteDB(t *testing.T) { + t.Parallel() + dir := t.TempDir() + src := filepath.Join(dir, "src.sqlite") + dst := filepath.Join(dir, "dst.sqlite") + require.NoError(t, os.WriteFile(src, []byte("dbcontents"), 0o644)) + // a WAL sidecar should be copied too + require.NoError(t, os.WriteFile(src+"-wal", []byte("wal"), 0o644)) + + require.NoError(t, copyLiteDB(src, dst)) + got, err := os.ReadFile(dst) + require.NoError(t, err) + require.Equal(t, "dbcontents", string(got)) + require.FileExists(t, dst+"-wal") + + // removeLiteDB deletes the file and sidecars; missing files are not an error + require.NoError(t, removeLiteDB(dst)) + require.NoFileExists(t, dst) + require.NoFileExists(t, dst+"-wal") + require.NoError(t, removeLiteDB(filepath.Join(dir, "does-not-exist.sqlite"))) +} + +func TestCopyLiteDBMissingSource(t *testing.T) { + t.Parallel() + dir := t.TempDir() + // copyLiteDB skips absent sidecars but the main file absent means nothing is copied; the + // destination simply does not get created. It should not error on missing source files. + require.NoError(t, copyLiteDB(filepath.Join(dir, "nope.sqlite"), filepath.Join(dir, "out.sqlite"))) +} diff --git a/tools/exit_certificate/step_g_order.go b/tools/exit_certificate/step_g_order.go new file mode 100644 index 000000000..0c847bd1f --- /dev/null +++ b/tools/exit_certificate/step_g_order.go @@ -0,0 +1,53 @@ +package exit_certificate + +import ( + "fmt" + "math/big" + "sort" + + agglayertypes "github.com/agglayer/aggkit/agglayer/types" + "github.com/agglayer/aggkit/tools/exit_certificate/bridgesyncerlite" +) + +func bigIntKey(v *big.Int) string { + if v == nil { + return "0" + } + return v.String() +} + +// reorderCertificateByDepositCount reorders certificate.BridgeExits to the canonical exit-tree order +// and returns the replay's on-chain metadata aligned to the reordered exits, so the caller can +// cross-check it against each exit's own metadata. leaves[i] is the BridgeEvent the replay of +// certificate.BridgeExits[i] emitted; its DepositCount is the on-chain leaf index. The parallel +// replay assigns deposit counts non-deterministically across exits, so the exits must be sorted by +// that count for the certificate to be consistent with the computed NewLocalExitRoot (agglayer +// rebuilds the LER by inserting the bridge exits in order). Because each exit maps directly to one +// replayed leaf by index, no content matching is needed and duplicate exits are handled trivially. +func reorderCertificateByDepositCount( + certificate *agglayertypes.Certificate, leaves []bridgesyncerlite.BridgeLeaf, +) ([][]byte, error) { + exits := certificate.BridgeExits + if len(leaves) != len(exits) { + return nil, fmt.Errorf("replayed leaf count %d != certificate bridge exit count %d", + len(leaves), len(exits)) + } + + order := make([]int, len(exits)) + for i := range order { + order[i] = i + } + sort.Slice(order, func(a, b int) bool { + return leaves[order[a]].DepositCount < leaves[order[b]].DepositCount + }) + + newExits := make([]*agglayertypes.BridgeExit, len(exits)) + onChainMetadata := make([][]byte, len(exits)) + for pos, idx := range order { + newExits[pos] = exits[idx] + onChainMetadata[pos] = leaves[idx].Metadata + } + + certificate.BridgeExits = newExits + return onChainMetadata, nil +} diff --git a/tools/exit_certificate/step_g_order_test.go b/tools/exit_certificate/step_g_order_test.go new file mode 100644 index 000000000..aaea09831 --- /dev/null +++ b/tools/exit_certificate/step_g_order_test.go @@ -0,0 +1,79 @@ +package exit_certificate + +import ( + "math/big" + "testing" + + agglayertypes "github.com/agglayer/aggkit/agglayer/types" + "github.com/agglayer/aggkit/tools/exit_certificate/bridgesyncerlite" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +func erc20Exit(originNet uint32, originAddr, dest common.Address, amount int64) *agglayertypes.BridgeExit { + return &agglayertypes.BridgeExit{ + TokenInfo: &agglayertypes.TokenInfo{OriginNetwork: originNet, OriginTokenAddress: originAddr}, + DestinationNetwork: 0, + DestinationAddress: dest, + Amount: big.NewInt(amount), + } +} + +func nativeExit(dest common.Address, amount int64) *agglayertypes.BridgeExit { + return &agglayertypes.BridgeExit{ + TokenInfo: nil, + DestinationNetwork: 0, + DestinationAddress: dest, + Amount: big.NewInt(amount), + } +} + +// leafWithDepositCount builds a replayed BridgeLeaf carrying the given deposit count and a metadata +// tag, as it would be captured from the replay of the matching certificate exit. +func leafWithDepositCount(depositCount uint32, metadata []byte) bridgesyncerlite.BridgeLeaf { + return bridgesyncerlite.BridgeLeaf{DepositCount: depositCount, Metadata: metadata} +} + +func TestReorderCertificateByDepositCount(t *testing.T) { + t.Parallel() + + tokenA := common.HexToAddress("0xaaaa") + destA := common.HexToAddress("0x1111") + destB := common.HexToAddress("0x2222") + destC := common.HexToAddress("0x3333") + + // Certificate order: [A, B, C], metadata tagged by original index. + exits := []*agglayertypes.BridgeExit{ + erc20Exit(1, tokenA, destA, 100), + nativeExit(destB, 200), + erc20Exit(1, tokenA, destC, 300), + } + cert := &agglayertypes.Certificate{BridgeExits: exits} + + // Replay assigned deposit counts C(0), A(1), B(2) — leaves are indexed by the original exit + // position, so leaves[0] is A's, leaves[1] is B's, leaves[2] is C's. + leaves := []bridgesyncerlite.BridgeLeaf{ + leafWithDepositCount(1, []byte{0xA}), + leafWithDepositCount(2, []byte{0xB}), + leafWithDepositCount(0, []byte{0xC}), + } + + onChainMeta, err := reorderCertificateByDepositCount(cert, leaves) + require.NoError(t, err) + + require.Equal(t, destC, cert.BridgeExits[0].DestinationAddress) + require.Equal(t, destA, cert.BridgeExits[1].DestinationAddress) + require.Equal(t, destB, cert.BridgeExits[2].DestinationAddress) + // the returned on-chain metadata is aligned to the reordered exits. + require.Equal(t, [][]byte{{0xC}, {0xA}, {0xB}}, onChainMeta) +} + +func TestReorderCertificateByDepositCountCountMismatch(t *testing.T) { + t.Parallel() + + cert := &agglayertypes.Certificate{BridgeExits: []*agglayertypes.BridgeExit{ + nativeExit(common.HexToAddress("0x1111"), 100), + }} + _, err := reorderCertificateByDepositCount(cert, nil) + require.ErrorContains(t, err, "!= certificate bridge exit count") +} diff --git a/tools/exit_certificate/step_g_test.go b/tools/exit_certificate/step_g_test.go new file mode 100644 index 000000000..7e6d5446c --- /dev/null +++ b/tools/exit_certificate/step_g_test.go @@ -0,0 +1,171 @@ +package exit_certificate + +import ( + "math/big" + "testing" + + agglayertypes "github.com/agglayer/aggkit/agglayer/types" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +func TestIsNativeBridgeExit(t *testing.T) { + t.Parallel() + + gasTokenNetwork := uint32(0) + gasTokenAddr := common.HexToAddress("0xGasToken") + + tests := []struct { + name string + ti *agglayertypes.TokenInfo + native bool + }{ + { + name: "nil TokenInfo is native", + ti: nil, + native: true, + }, + { + name: "zero origin address is native (ETH)", + ti: &agglayertypes.TokenInfo{OriginNetwork: 0, OriginTokenAddress: common.Address{}}, + native: true, + }, + { + name: "gas token address is native", + ti: &agglayertypes.TokenInfo{OriginNetwork: gasTokenNetwork, OriginTokenAddress: gasTokenAddr}, + native: true, + }, + { + name: "non-native ERC-20", + ti: &agglayertypes.TokenInfo{OriginNetwork: 0, OriginTokenAddress: common.HexToAddress("0x1111")}, + native: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got := isNativeBridgeExit(tc.ti, gasTokenNetwork, gasTokenAddr) + require.Equal(t, tc.native, got) + }) + } +} + +func TestFindTokenAddress_Found(t *testing.T) { + t.Parallel() + + originAddr := common.HexToAddress("0x1111111111111111111111111111111111111111") + wrappedAddr := common.HexToAddress("0x2222222222222222222222222222222222222222") + + tokenMap := map[tokenOriginKey]common.Address{ + {0, originAddr}: wrappedAddr, + } + exit := &agglayertypes.BridgeExit{ + TokenInfo: &agglayertypes.TokenInfo{OriginNetwork: 0, OriginTokenAddress: originAddr}, + } + + addr, err := findTokenAddress(exit, tokenMap) + require.NoError(t, err) + require.Equal(t, wrappedAddr, addr) +} + +func TestFindTokenAddress_NotFound(t *testing.T) { + t.Parallel() + + originAddr := common.HexToAddress("0x1111111111111111111111111111111111111111") + exit := &agglayertypes.BridgeExit{ + TokenInfo: &agglayertypes.TokenInfo{OriginNetwork: 0, OriginTokenAddress: originAddr}, + } + + _, err := findTokenAddress(exit, map[tokenOriginKey]common.Address{}) + require.Error(t, err) + require.Contains(t, err.Error(), "not found in token map") +} + +func TestFindTokenAddress_NilTokenInfo(t *testing.T) { + t.Parallel() + + exit := &agglayertypes.BridgeExit{TokenInfo: nil} + _, err := findTokenAddress(exit, map[tokenOriginKey]common.Address{}) + require.Error(t, err) + require.Contains(t, err.Error(), "nil TokenInfo") +} + +func TestBuildLBTTokenMap(t *testing.T) { + t.Parallel() + + origin1 := common.HexToAddress("0x1111111111111111111111111111111111111111") + wrapped1 := common.HexToAddress("0x2222222222222222222222222222222222222222") + origin2 := common.HexToAddress("0x3333333333333333333333333333333333333333") + wrapped2 := common.HexToAddress("0x4444444444444444444444444444444444444444") + + entries := []LBTEntry{ + {OriginNetwork: 0, OriginTokenAddress: origin1, WrappedTokenAddress: wrapped1}, + {OriginNetwork: 1, OriginTokenAddress: origin2, WrappedTokenAddress: wrapped2}, + // zero WrappedTokenAddress should be excluded (native entry) + {OriginNetwork: 0, OriginTokenAddress: origin1, WrappedTokenAddress: common.Address{}}, + } + + m := buildLBTTokenMap(entries) + require.Len(t, m, 2) + require.Equal(t, wrapped1, m[tokenOriginKey{0, origin1}]) + require.Equal(t, wrapped2, m[tokenOriginKey{1, origin2}]) +} + +func TestBuildLBTTokenMap_Empty(t *testing.T) { + t.Parallel() + m := buildLBTTokenMap(nil) + require.Empty(t, m) +} + +func TestEncodeERC20ApproveCallRaw_Length(t *testing.T) { + t.Parallel() + + spender := common.HexToAddress("0x1234567890123456789012345678901234567890") + amount := big.NewInt(1000) + + data := encodeERC20ApproveCallRaw(spender, amount) + // 4 bytes selector + 32 bytes spender + 32 bytes amount = 68 + require.Len(t, data, 68) +} + +func TestEncodeERC20ApproveCallRaw_NilAmount(t *testing.T) { + t.Parallel() + + spender := common.HexToAddress("0x1234567890123456789012345678901234567890") + data := encodeERC20ApproveCallRaw(spender, nil) + require.Len(t, data, 68) +} + +func TestEncodeERC20ApproveCallRaw_Selector(t *testing.T) { + t.Parallel() + + // keccak256("approve(address,uint256)")[:4] = 0x095ea7b3 + spender := common.HexToAddress("0x1234567890123456789012345678901234567890") + data := encodeERC20ApproveCallRaw(spender, big.NewInt(1)) + require.Equal(t, []byte{0x09, 0x5e, 0xa7, 0xb3}, data[:4]) +} + +func TestEncodeBridgeAssetCallRaw_NonNil(t *testing.T) { + t.Parallel() + + destAddr := common.HexToAddress("0xDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD") + tokenAddr := common.HexToAddress("0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") + + data := encodeBridgeAssetCallRaw(1, destAddr, big.NewInt(500), tokenAddr) + require.NotEmpty(t, data) + // ABI-encoded: 4 selector + 5 * 32 = 164 bytes minimum + require.Greater(t, len(data), 4) +} + +func TestEncodeBridgeAssetCallRaw_NilAmount(t *testing.T) { + t.Parallel() + + destAddr := common.HexToAddress("0xDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD") + tokenAddr := common.HexToAddress("0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") + + require.NotPanics(t, func() { + data := encodeBridgeAssetCallRaw(0, destAddr, nil, tokenAddr) + require.NotEmpty(t, data) + }) +} diff --git a/tools/exit_certificate/step_h.go b/tools/exit_certificate/step_h.go new file mode 100644 index 000000000..330f02609 --- /dev/null +++ b/tools/exit_certificate/step_h.go @@ -0,0 +1,90 @@ +package exit_certificate + +import ( + "context" + "fmt" + + "github.com/agglayer/aggkit/agglayer" + "github.com/agglayer/aggkit/log" + "github.com/ethereum/go-ethereum/common" +) + +// RunStepH fetches the PreviousLocalExitRoot for the L2 network from the agglayer +// by calling GetNetworkInfo and reading the SettledLER field. +// gResult is the output of Step G; when provided, its InitialLocalExitRoot is compared +// against the agglayer's settled LER and an error is returned on mismatch. +func RunStepH(ctx context.Context, cfg *Config, gResult *StepGResult) (*StepHResult, error) { + log.Info("═══════════════════════════════════════════") + log.Info(" STEP H - Fetch PreviousLocalExitRoot") + log.Info("═══════════════════════════════════════════") + + agglayerClientCfg := cfg.Options.AgglayerClient + if agglayerClientCfg.GRPC == nil || agglayerClientCfg.GRPC.URL == "" { + return nil, fmt.Errorf("agglayerClient.grpc.url is required for step H") + } + + client, err := agglayer.NewAgglayerClient(agglayerClientCfg, log.GetDefaultLogger()) + if err != nil { + return nil, fmt.Errorf("create agglayer client: %w", err) + } + + return runStepH(ctx, cfg, client, gResult) +} + +// runStepH is the client-injectable core of RunStepH (tests pass an agglayer client mock in place of +// the real gRPC client). It queries the network info, refuses to proceed on a pending certificate, +// and derives the PreviousLocalExitRoot / next height (optionally cross-checking gResult). +func runStepH( + ctx context.Context, cfg *Config, client agglayer.AgglayerClientInterface, gResult *StepGResult, +) (*StepHResult, error) { + info, err := client.GetNetworkInfo(ctx, cfg.L2NetworkID) + if err != nil { + return nil, fmt.Errorf("get network info (network %d): %w", cfg.L2NetworkID, err) + } + + // Refuse to proceed when the agglayer still has a non-settled (open) certificate for this + // network: building a new exit certificate on top of a pending one would conflict. + if info.LatestPendingStatus != nil && info.LatestPendingStatus.IsOpen() { + pendingHeight := "unknown" + if info.LatestPendingHeight != nil { + pendingHeight = fmt.Sprintf("%d", *info.LatestPendingHeight) + } + return nil, fmt.Errorf( + "network %d has a pending certificate (status %s, height %s) that is not settled yet — "+ + "wait for it to settle before generating a new exit certificate", + cfg.L2NetworkID, info.LatestPendingStatus, pendingHeight, + ) + } + + var prevLER common.Hash + var nextHeight uint64 + if info.SettledLER != nil { + prevLER = *info.SettledLER + } else { + log.Infof("No settled certificate for network %d — PreviousLocalExitRoot is zero", cfg.L2NetworkID) + } + if info.SettledHeight != nil { + nextHeight = *info.SettledHeight + 1 + } + + log.Infof("PreviousLocalExitRoot (agglayer): %s", prevLER.Hex()) + log.Infof("Next certificate height: %d", nextHeight) + + if gResult != nil { + log.Infof("InitialLocalExitRoot (L2 chain): %s", gResult.InitialLocalExitRoot.Hex()) + if gResult.InitialLocalExitRoot != prevLER { + return nil, fmt.Errorf( + "LocalExitRoot mismatch: Step G started from %s (read from bridgeContract) but agglayer last settled %s — "+ + "this situation should not happen: the sequencer must be stopped before starting to generate "+ + "the certificate, so that the L2 state (and its LER) stays frozen throughout the whole pipeline; "+ + "if you see this, the chain advanced or a new certificate was settled while the certificate was "+ + "being generated — stop the sequencer and re-run from the beginning", + gResult.InitialLocalExitRoot.Hex(), prevLER.Hex(), + ) + } + log.Info("✅ InitialLocalExitRoot matches agglayer settled LER") + } + + log.Info("STEP H complete") + return &StepHResult{PreviousLocalExitRoot: prevLER, Height: nextHeight}, nil +} diff --git a/tools/exit_certificate/step_h_test.go b/tools/exit_certificate/step_h_test.go new file mode 100644 index 000000000..8cc0f32f0 --- /dev/null +++ b/tools/exit_certificate/step_h_test.go @@ -0,0 +1,123 @@ +package exit_certificate + +import ( + "context" + "errors" + "testing" + + "github.com/agglayer/aggkit/agglayer/mocks" + agglayertypes "github.com/agglayer/aggkit/agglayer/types" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func ptrStatus(s agglayertypes.CertificateStatus) *agglayertypes.CertificateStatus { return &s } +func ptrUint64(v uint64) *uint64 { return &v } + +// TestRunStepHPendingCertificateRejected covers the guard that refuses to proceed when the agglayer +// still has a non-settled (open) certificate for the network. +func TestRunStepHPendingCertificateRejected(t *testing.T) { + t.Parallel() + + t.Run("open certificate with known height", func(t *testing.T) { + t.Parallel() + client := mocks.NewAgglayerClientMock(t) + client.EXPECT().GetNetworkInfo(mock.Anything, uint32(7)).Return(agglayertypes.NetworkInfo{ + LatestPendingStatus: ptrStatus(agglayertypes.Pending), + LatestPendingHeight: ptrUint64(3), + }, nil) + + _, err := runStepH(context.Background(), &Config{L2NetworkID: 7}, client, nil) + require.ErrorContains(t, err, "network 7 has a pending certificate") + require.ErrorContains(t, err, "status Pending") + require.ErrorContains(t, err, "height 3") + }) + + t.Run("open certificate with unknown height", func(t *testing.T) { + t.Parallel() + client := mocks.NewAgglayerClientMock(t) + // Candidate is also an open status; with a nil height the message reports "unknown". + client.EXPECT().GetNetworkInfo(mock.Anything, uint32(7)).Return(agglayertypes.NetworkInfo{ + LatestPendingStatus: ptrStatus(agglayertypes.Candidate), + }, nil) + + _, err := runStepH(context.Background(), &Config{L2NetworkID: 7}, client, nil) + require.ErrorContains(t, err, "height unknown") + }) +} + +// TestRunStepHSettled covers the happy paths once no open certificate blocks the step: the settled +// LER and next height are derived from the network info. +func TestRunStepHSettled(t *testing.T) { + t.Parallel() + settledLER := common.HexToHash("0xabc") + + t.Run("settled certificate present", func(t *testing.T) { + t.Parallel() + client := mocks.NewAgglayerClientMock(t) + client.EXPECT().GetNetworkInfo(mock.Anything, uint32(1)).Return(agglayertypes.NetworkInfo{ + SettledLER: &settledLER, + SettledHeight: ptrUint64(4), + }, nil) + + res, err := runStepH(context.Background(), &Config{L2NetworkID: 1}, client, nil) + require.NoError(t, err) + require.Equal(t, settledLER, res.PreviousLocalExitRoot) + require.Equal(t, uint64(5), res.Height) // settled height + 1 + }) + + t.Run("no settled certificate yet → zero prev LER", func(t *testing.T) { + t.Parallel() + // A settled InError status is closed (not open), so the guard passes; no SettledLER → zero. + client := mocks.NewAgglayerClientMock(t) + client.EXPECT().GetNetworkInfo(mock.Anything, uint32(1)).Return(agglayertypes.NetworkInfo{ + LatestPendingStatus: ptrStatus(agglayertypes.Settled), + }, nil) + + res, err := runStepH(context.Background(), &Config{L2NetworkID: 1}, client, nil) + require.NoError(t, err) + require.Equal(t, common.Hash{}, res.PreviousLocalExitRoot) + require.Equal(t, uint64(0), res.Height) + }) +} + +// TestRunStepHLERMismatch covers the cross-check against Step G's InitialLocalExitRoot. +func TestRunStepHLERMismatch(t *testing.T) { + t.Parallel() + settledLER := common.HexToHash("0xabc") + + t.Run("mismatch is an error", func(t *testing.T) { + t.Parallel() + client := mocks.NewAgglayerClientMock(t) + client.EXPECT().GetNetworkInfo(mock.Anything, uint32(1)).Return(agglayertypes.NetworkInfo{ + SettledLER: &settledLER, + }, nil) + + gResult := &StepGResult{InitialLocalExitRoot: common.HexToHash("0xdead")} + _, err := runStepH(context.Background(), &Config{L2NetworkID: 1}, client, gResult) + require.ErrorContains(t, err, "LocalExitRoot mismatch") + }) + + t.Run("match succeeds", func(t *testing.T) { + t.Parallel() + client := mocks.NewAgglayerClientMock(t) + client.EXPECT().GetNetworkInfo(mock.Anything, uint32(1)).Return(agglayertypes.NetworkInfo{ + SettledLER: &settledLER, + }, nil) + + gResult := &StepGResult{InitialLocalExitRoot: settledLER} + res, err := runStepH(context.Background(), &Config{L2NetworkID: 1}, client, gResult) + require.NoError(t, err) + require.Equal(t, settledLER, res.PreviousLocalExitRoot) + }) +} + +func TestRunStepHGetNetworkInfoError(t *testing.T) { + t.Parallel() + client := mocks.NewAgglayerClientMock(t) + client.EXPECT().GetNetworkInfo(mock.Anything, mock.Anything).Return(agglayertypes.NetworkInfo{}, errors.New("boom")) + + _, err := runStepH(context.Background(), &Config{L2NetworkID: 1}, client, nil) + require.ErrorContains(t, err, "get network info") +} diff --git a/tools/exit_certificate/step_i.go b/tools/exit_certificate/step_i.go new file mode 100644 index 000000000..39a3a92bc --- /dev/null +++ b/tools/exit_certificate/step_i.go @@ -0,0 +1,161 @@ +package exit_certificate + +import ( + "context" + "encoding/json" + "fmt" + "math/big" + + agglayertypes "github.com/agglayer/aggkit/agglayer/types" + "github.com/agglayer/aggkit/log" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" +) + +var ( + // keccak256("UpdateL1InfoTreeV2(bytes32,uint32,uint256,uint64)") + // leafCount is indexed (topics[1]); currentL1InfoRoot, blockhash, minTimestamp are in data. + updateL1InfoTreeV2Topic = common.HexToHash("0xaf6c6cd7790e0180a4d22eb8ed846e55846f54ed10e5946db19972b5a0813a59") +) + +// RunStepI assembles the final certificate by applying the NewLocalExitRoot from Step G, +// the PreviousLocalExitRoot from Step H, and the L1InfoTreeLeafCount from L1. +func RunStepI( + ctx context.Context, cfg *Config, certificate *agglayertypes.Certificate, gResult *StepGResult, hResult *StepHResult, +) error { + log.Info("═══════════════════════════════════════════") + log.Info(" STEP I - Assemble final certificate") + log.Info("═══════════════════════════════════════════") + + if certificate == nil { + return fmt.Errorf("certificate is nil") + } + if gResult == nil { + return fmt.Errorf("step G result is nil") + } + + certificate.NewLocalExitRoot = gResult.NewLocalExitRoot + log.Infof("NewLocalExitRoot: %s", certificate.NewLocalExitRoot.Hex()) + + if len(gResult.BridgeExitMetadata) > 0 { + if len(gResult.BridgeExitMetadata) != len(certificate.BridgeExits) { + return fmt.Errorf("step G metadata count (%d) does not match bridge exits count (%d)", + len(gResult.BridgeExitMetadata), len(certificate.BridgeExits)) + } + for i, meta := range gResult.BridgeExitMetadata { + // aggsender applies crypto.Keccak256 to the raw BridgeEvent metadata before + // storing it in BridgeExit.Metadata (see aggsender/converters/bridge_exit_converter.go + // convertBridgeMetadata). We must do the same so BridgeExit.Hash() matches. + certificate.BridgeExits[i].Metadata = crypto.Keccak256(meta) + } + log.Infof("Applied bridge exit metadata from Step G (%d entries)", len(gResult.BridgeExitMetadata)) + } + + if hResult != nil { + certificate.PrevLocalExitRoot = hResult.PreviousLocalExitRoot + certificate.Height = hResult.Height + log.Infof("PreviousLocalExitRoot: %s", certificate.PrevLocalExitRoot.Hex()) + log.Infof("Height: %d", certificate.Height) + } + + leafCount, err := fetchL1InfoTreeLeafCount(ctx, cfg) + if err != nil { + return fmt.Errorf("could not fetch L1InfoTreeLeafCount: %w", err) + } else { + certificate.L1InfoTreeLeafCount = leafCount + log.Infof("L1InfoTreeLeafCount: %d", leafCount) + } + + log.Info("STEP I complete") + return nil +} + +// fetchL1InfoTreeLeafCount scans L1 backwards from the latest L1 block looking for the +// most recent UpdateL1InfoTreeV2 event emitted by cfg.L1GlobalExitRootAddress and returns +// its indexed leafCount field. +func fetchL1InfoTreeLeafCount(ctx context.Context, cfg *Config) (uint32, error) { + if cfg.L1RPCURL == "" { + return 0, fmt.Errorf("l1RpcUrl not configured") + } + if cfg.L1GlobalExitRootAddress == (common.Address{}) { + return 0, fmt.Errorf("l1GlobalExitRootAddress not configured") + } + + toBlock, err := resolveLatestBlock(ctx, cfg.L1RPCURL) + if err != nil { + return 0, fmt.Errorf("resolve latest L1 block: %w", err) + } + chunkSize := uint64(cfg.Options.BlockRange) + if chunkSize == 0 { + chunkSize = defaultBlockRange + } + + log.Infof("Scanning L1 backwards for UpdateL1InfoTreeV2 (contract=%s, from block %d)", + cfg.L1GlobalExitRootAddress.Hex(), toBlock) + + // Scan backwards in chunks until we find an event. + for end := toBlock; ; { + var start uint64 + if end >= chunkSize { + start = end - chunkSize + 1 + } + + leafCount, found, err := queryUpdateL1InfoTreeV2(ctx, cfg.L1RPCURL, cfg.L1GlobalExitRootAddress, start, end) + if err != nil { + log.Warnf("eth_getLogs [%d-%d] error: %v", start, end, err) + } else if found { + log.Infof("Found UpdateL1InfoTreeV2 at block range [%d-%d]: leafCount=%d", start, end, leafCount) + return leafCount, nil + } + + if start == 0 { + break + } + end = start - 1 + } + + return 0, fmt.Errorf("no UpdateL1InfoTreeV2 event found between block 0 and %d", toBlock) +} + +// queryUpdateL1InfoTreeV2 fetches UpdateL1InfoTreeV2 logs in [fromBlock, toBlock] and returns +// the leafCount from the LAST (most recent) log found, or (0, false, nil) if none. +func queryUpdateL1InfoTreeV2( + ctx context.Context, rpcURL string, contractAddr common.Address, + fromBlock, toBlock uint64, +) (leafCount uint32, found bool, err error) { + result, err := singleRPC(ctx, rpcURL, "eth_getLogs", []any{ + map[string]any{ + "address": contractAddr.Hex(), + "topics": []string{updateL1InfoTreeV2Topic.Hex()}, + "fromBlock": toBlockTag(fromBlock), + "toBlock": toBlockTag(toBlock), + }, + }, defaultRetries) + if err != nil { + return 0, false, err + } + + var logs []struct { + Topics []string `json:"topics"` + } + if err := json.Unmarshal(result, &logs); err != nil { + return 0, false, fmt.Errorf("unmarshal UpdateL1InfoTreeV2 logs: %w", err) + } + if len(logs) == 0 { + return 0, false, nil + } + + // Take the LAST log (highest block number) in this range. + last := logs[len(logs)-1] + if len(last.Topics) < minTopicsForLeaf { + return 0, false, fmt.Errorf("UpdateL1InfoTreeV2 log has only %d topics", len(last.Topics)) + } + + // topics[1] is the indexed leafCount (uint32), ABI-encoded as a 32-byte big-endian value. + topicBytes := common.FromHex(last.Topics[1]) + lc, err := safeUint32(new(big.Int).SetBytes(topicBytes)) + if err != nil { + return 0, false, fmt.Errorf("decode leafCount from topics[1]: %w", err) + } + return lc, true, nil +} diff --git a/tools/exit_certificate/step_sign.go b/tools/exit_certificate/step_sign.go new file mode 100644 index 000000000..243d1aa48 --- /dev/null +++ b/tools/exit_certificate/step_sign.go @@ -0,0 +1,80 @@ +package exit_certificate + +import ( + "context" + "encoding/json" + "fmt" + + agglayertypes "github.com/agglayer/aggkit/agglayer/types" + "github.com/agglayer/aggkit/aggsender/validator" + "github.com/agglayer/aggkit/log" + "github.com/agglayer/go_signer/signer" +) + +// RunStepSign signs the certificate with the configured keystore and sets AggchainData +// to an AggchainDataMultisig containing the ECDSA signature. +func RunStepSign( + ctx context.Context, cfg *Config, cert *agglayertypes.Certificate, +) (*agglayertypes.Certificate, error) { + log.Info("═══════════════════════════════════════════") + log.Info(" STEP SIGN — Sign exit certificate") + log.Info("═══════════════════════════════════════════") + + if cfg.SignerConfig.Method == "" { + return nil, fmt.Errorf("signerConfig.Method is required for signing") + } + + chainID, err := fetchL2ChainID(ctx, cfg.L2RPCURL) + if err != nil { + return nil, fmt.Errorf("fetch L2 chain ID: %w", err) + } + + certSigner, err := signer.NewSigner(ctx, chainID, cfg.SignerConfig, "exit-certificate", log.GetDefaultLogger()) + if err != nil { + return nil, fmt.Errorf("create signer (method=%s): %w", cfg.SignerConfig.Method, err) + } + if err := certSigner.Initialize(ctx); err != nil { + return nil, fmt.Errorf("initialize signer: %w", err) + } + log.Infof("Signer public address: %s", certSigner.PublicAddress().Hex()) + + log.Infof("Certificate to sign: networkID=%d height=%d prevLER=%s newLER=%s bridgeExits=%d importedBridgeExits=%d", + cert.NetworkID, cert.Height, + cert.PrevLocalExitRoot.Hex(), cert.NewLocalExitRoot.Hex(), + len(cert.BridgeExits), len(cert.ImportedBridgeExits)) + log.Infof("CertificateID: %s", cert.CertificateID().Hex()) + + hashToSign, err := validator.HashCertificateToSign(cert) + if err != nil { + return nil, fmt.Errorf("hash certificate to sign: %w", err) + } + log.Infof("Hash to sign: %s", hashToSign.Hex()) + + sig, err := certSigner.SignHash(ctx, hashToSign) + if err != nil { + return nil, fmt.Errorf("sign certificate hash: %w", err) + } + + cert.AggchainData = &agglayertypes.AggchainDataMultisig{ + Multisig: &agglayertypes.Multisig{ + Signatures: []agglayertypes.ECDSAMultisigEntry{ + {Index: 0, Signature: sig}, + }, + }, + } + + log.Info("STEP SIGN complete: certificate signed") + return cert, nil +} + +func fetchL2ChainID(ctx context.Context, rpcURL string) (uint64, error) { + result, err := singleRPC(ctx, rpcURL, "eth_chainId", nil, defaultRetries) + if err != nil { + return 0, err + } + var hexStr string + if err := json.Unmarshal(result, &hexStr); err != nil { + return 0, fmt.Errorf("parse chain ID: %w", err) + } + return hexToUint64(hexStr), nil +} diff --git a/tools/exit_certificate/step_sign_test.go b/tools/exit_certificate/step_sign_test.go new file mode 100644 index 000000000..22e805189 --- /dev/null +++ b/tools/exit_certificate/step_sign_test.go @@ -0,0 +1,47 @@ +package exit_certificate + +import ( + "context" + "encoding/json" + "testing" + + agglayertypes "github.com/agglayer/aggkit/agglayer/types" + signertypes "github.com/agglayer/go_signer/signer/types" + "github.com/ethereum/go-ethereum/accounts/keystore" + "github.com/stretchr/testify/require" +) + +func TestRunStepSignRequiresMethod(t *testing.T) { + t.Parallel() + _, err := RunStepSign(context.Background(), &Config{}, &agglayertypes.Certificate{}) + require.ErrorContains(t, err, "signerConfig.Method is required") +} + +func TestRunStepSignLocalKeystore(t *testing.T) { + t.Parallel() + // Generate a real go-ethereum keystore the local signer can load. + ks := keystore.NewKeyStore(t.TempDir(), keystore.LightScryptN, keystore.LightScryptP) + const pass = "test-password" + acc, err := ks.NewAccount(pass) + require.NoError(t, err) + + srv := newRPCStub(t, func(method string, _ []any) (json.RawMessage, *jsonRPCError) { + require.Equal(t, "eth_chainId", method) + return quoted("0x1"), nil + }) + + cfg := &Config{ + L2RPCURL: srv.URL, + SignerConfig: signertypes.SignerConfig{ + Method: "local", + Config: map[string]any{"path": acc.URL.Path, "password": pass}, + }, + } + + signed, err := RunStepSign(context.Background(), cfg, &agglayertypes.Certificate{NetworkID: 1}) + require.NoError(t, err) + require.NotNil(t, signed.AggchainData) + multisig, ok := signed.AggchainData.(*agglayertypes.AggchainDataMultisig) + require.True(t, ok) + require.Len(t, multisig.Multisig.Signatures, 1) +} diff --git a/tools/exit_certificate/step_submit.go b/tools/exit_certificate/step_submit.go new file mode 100644 index 000000000..58a758234 --- /dev/null +++ b/tools/exit_certificate/step_submit.go @@ -0,0 +1,89 @@ +package exit_certificate + +import ( + "context" + "fmt" + + "github.com/agglayer/aggkit/agglayer" + agglayertypes "github.com/agglayer/aggkit/agglayer/types" + "github.com/agglayer/aggkit/log" + "github.com/ethereum/go-ethereum/common" +) + +// StepSubmitResult holds the output of the SUBMIT step. +type StepSubmitResult struct { + CertificateHash common.Hash `json:"certificateHash"` + // L1LatestBlockBeforeSubmittingCertificate is the latest L1 block number + // captured right before the certificate was sent to the agglayer. It marks + // the L1 starting point from which to look for the block where the agglayer + // settles this certificate on L1 (e.g. for the exit certificate claimer). + L1LatestBlockBeforeSubmittingCertificate uint64 `json:"l1LatestBlockBeforeSubmittingCertificate"` +} + +// RunStepSubmit sends the signed certificate to the agglayer via gRPC and +// returns the certificate hash assigned by the agglayer. +// Requires options.agglayerClient.grpc.url. +func RunStepSubmit(ctx context.Context, cfg *Config, cert *agglayertypes.Certificate) (*StepSubmitResult, error) { + log.Info("═══════════════════════════════════════════") + log.Info(" STEP SUBMIT - Send certificate to agglayer") + log.Info("═══════════════════════════════════════════") + + agglayerClientCfg := cfg.Options.AgglayerClient + if agglayerClientCfg.GRPC == nil || agglayerClientCfg.GRPC.URL == "" { + return nil, fmt.Errorf("agglayerClient.grpc.url is required for step submit") + } + + client, err := agglayer.NewAgglayerClient(agglayerClientCfg, log.GetDefaultLogger()) + if err != nil { + return nil, fmt.Errorf("create agglayer gRPC client: %w", err) + } + + return runStepSubmit(ctx, cfg, client, cert) +} + +// runStepSubmit is the client-injectable core of RunStepSubmit (tests pass an agglayer client mock in +// place of the real gRPC client). It rejects submission while a non-closed certificate is pending, +// captures the latest L1 block right before submitting, and sends the certificate. +func runStepSubmit( + ctx context.Context, cfg *Config, client agglayer.AgglayerClientInterface, cert *agglayertypes.Certificate, +) (*StepSubmitResult, error) { + log.Infof("Checking for pending certificate on network %d...", cfg.L2NetworkID) + pending, err := client.GetLatestPendingCertificateHeader(ctx, cfg.L2NetworkID) + if err != nil { + return nil, fmt.Errorf("check pending certificate for network %d: %w", cfg.L2NetworkID, err) + } + if pending != nil && !pending.Status.IsClosed() { + return nil, fmt.Errorf( + "network %d already has a pending certificate (hash: %s, height: %d, status: %s)"+ + " — wait for it to settle before submitting a new one", + cfg.L2NetworkID, pending.CertificateID.Hex(), pending.Height, pending.Status, + ) + } + if pending != nil { + log.Infof("Latest certificate on network %d is already closed (hash: %s, status: %s), proceeding with submission", + cfg.L2NetworkID, pending.CertificateID.Hex(), pending.Status) + } else { + log.Info("No pending certificate found, proceeding with submission") + } + + if cfg.L1RPCURL == "" { + return nil, fmt.Errorf("l1RpcUrl is required for step submit to capture the latest L1 block") + } + l1LatestBlock, err := resolveLatestBlock(ctx, cfg.L1RPCURL) + if err != nil { + return nil, fmt.Errorf("capture latest L1 block before submission: %w", err) + } + log.Infof("Captured latest L1 block before submission: %d", l1LatestBlock) + + certHash, err := client.SendCertificate(ctx, cert) + if err != nil { + return nil, fmt.Errorf("send certificate to agglayer: %w", err) + } + + log.Infof("Certificate accepted by agglayer. Hash: %s", certHash.Hex()) + log.Info("STEP SUBMIT complete") + return &StepSubmitResult{ + CertificateHash: certHash, + L1LatestBlockBeforeSubmittingCertificate: l1LatestBlock, + }, nil +} diff --git a/tools/exit_certificate/step_submit_test.go b/tools/exit_certificate/step_submit_test.go new file mode 100644 index 000000000..49a007dfb --- /dev/null +++ b/tools/exit_certificate/step_submit_test.go @@ -0,0 +1,113 @@ +package exit_certificate + +import ( + "context" + "encoding/json" + "errors" + "testing" + + "github.com/agglayer/aggkit/agglayer/mocks" + agglayertypes "github.com/agglayer/aggkit/agglayer/types" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +// submitConfig wires the L1 RPC URL used to capture the latest L1 block before submission. +func submitConfig(l1URL string) *Config { + return &Config{L1RPCURL: l1URL, L2NetworkID: 1} +} + +func TestRunStepSubmitSuccess(t *testing.T) { + t.Parallel() + certHash := common.HexToHash("0xc0ffee") + + // L1 stub: eth_blockNumber → 0x1a4 (420), the block captured before submission. + l1 := newRPCStub(t, func(method string, _ []any) (json.RawMessage, *jsonRPCError) { + require.Equal(t, rpcMethodEthBlockNumber, method) + return quoted("0x1a4"), nil + }) + + client := mocks.NewAgglayerClientMock(t) + client.EXPECT().GetLatestPendingCertificateHeader(mock.Anything, uint32(1)).Return(nil, nil) + client.EXPECT().SendCertificate(mock.Anything, mock.Anything).Return(certHash, nil) + + res, err := runStepSubmit(context.Background(), submitConfig(l1.URL), client, &agglayertypes.Certificate{}) + require.NoError(t, err) + require.Equal(t, certHash, res.CertificateHash) + require.Equal(t, uint64(420), res.L1LatestBlockBeforeSubmittingCertificate) +} + +func TestRunStepSubmitClosedPendingProceeds(t *testing.T) { + t.Parallel() + // A closed (Settled) latest certificate does not block a new submission. + l1 := newRPCStub(t, func(string, []any) (json.RawMessage, *jsonRPCError) { + return quoted("0x1"), nil + }) + client := mocks.NewAgglayerClientMock(t) + client.EXPECT().GetLatestPendingCertificateHeader(mock.Anything, uint32(1)).Return( + &agglayertypes.CertificateHeader{Status: agglayertypes.Settled, CertificateID: common.HexToHash("0xaa")}, nil) + client.EXPECT().SendCertificate(mock.Anything, mock.Anything).Return(common.HexToHash("0xbb"), nil) + + res, err := runStepSubmit(context.Background(), submitConfig(l1.URL), client, &agglayertypes.Certificate{}) + require.NoError(t, err) + require.Equal(t, common.HexToHash("0xbb"), res.CertificateHash) +} + +func TestRunStepSubmitRequiresL1RPC(t *testing.T) { + t.Parallel() + // Pending check passes (no pending cert) but l1RpcUrl is unset → the L1-capture guard fires. + client := mocks.NewAgglayerClientMock(t) + client.EXPECT().GetLatestPendingCertificateHeader(mock.Anything, uint32(1)).Return(nil, nil) + + _, err := runStepSubmit(context.Background(), submitConfig(""), client, &agglayertypes.Certificate{}) + require.ErrorContains(t, err, "l1RpcUrl is required for step submit") +} + +func TestRunStepSubmitL1CaptureError(t *testing.T) { + t.Parallel() + // resolveLatestBlock fails (RPC error) → the capture-latest-block error is returned. + l1 := newRPCStub(t, func(string, []any) (json.RawMessage, *jsonRPCError) { + return nil, revertErr() + }) + client := mocks.NewAgglayerClientMock(t) + client.EXPECT().GetLatestPendingCertificateHeader(mock.Anything, uint32(1)).Return(nil, nil) + + _, err := runStepSubmit(context.Background(), submitConfig(l1.URL), client, &agglayertypes.Certificate{}) + require.ErrorContains(t, err, "capture latest L1 block before submission") +} + +func TestRunStepSubmitPendingCertificateRejected(t *testing.T) { + t.Parallel() + client := mocks.NewAgglayerClientMock(t) + client.EXPECT().GetLatestPendingCertificateHeader(mock.Anything, uint32(1)).Return( + &agglayertypes.CertificateHeader{ + Status: agglayertypes.Pending, CertificateID: common.HexToHash("0xaa"), Height: 9, + }, nil) + + _, err := runStepSubmit(context.Background(), submitConfig("http://l1"), client, &agglayertypes.Certificate{}) + require.ErrorContains(t, err, "already has a pending certificate") + require.ErrorContains(t, err, "height: 9") +} + +func TestRunStepSubmitPendingCheckError(t *testing.T) { + t.Parallel() + client := mocks.NewAgglayerClientMock(t) + client.EXPECT().GetLatestPendingCertificateHeader(mock.Anything, mock.Anything).Return(nil, errors.New("boom")) + + _, err := runStepSubmit(context.Background(), submitConfig("http://l1"), client, &agglayertypes.Certificate{}) + require.ErrorContains(t, err, "check pending certificate") +} + +func TestRunStepSubmitSendError(t *testing.T) { + t.Parallel() + l1 := newRPCStub(t, func(string, []any) (json.RawMessage, *jsonRPCError) { + return quoted("0x1"), nil + }) + client := mocks.NewAgglayerClientMock(t) + client.EXPECT().GetLatestPendingCertificateHeader(mock.Anything, uint32(1)).Return(nil, nil) + client.EXPECT().SendCertificate(mock.Anything, mock.Anything).Return(common.Hash{}, errors.New("rejected")) + + _, err := runStepSubmit(context.Background(), submitConfig(l1.URL), client, &agglayertypes.Certificate{}) + require.ErrorContains(t, err, "send certificate to agglayer") +} diff --git a/tools/exit_certificate/step_wait.go b/tools/exit_certificate/step_wait.go new file mode 100644 index 000000000..ebc3acdda --- /dev/null +++ b/tools/exit_certificate/step_wait.go @@ -0,0 +1,468 @@ +package exit_certificate + +import ( + "context" + "encoding/json" + "fmt" + "math/big" + "time" + + "github.com/agglayer/aggkit/agglayer" + agglayertypes "github.com/agglayer/aggkit/agglayer/types" + "github.com/agglayer/aggkit/log" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" +) + +const ( + waitPollInterval = 5 * time.Second + // verifyBatchesDataLen is the ABI-encoded data length of VerifyBatchesTrustedAggregator: + // numBatch (uint64) + stateRoot (bytes32) + exitRoot (bytes32), each padded to 32 bytes. + verifyBatchesDataLen = 96 + // rollupManagerSelector is keccak256("rollupManager()")[:4], the getter exposed by the + // consensus contract (PolygonConsensusBase, i.e. sovereignRollupAddr) that returns the + // address of the PolygonRollupManager it belongs to. + rollupManagerSelector = "0x49b7b802" + // updateL1InfoTreeMinTopics is the minimum number of topics an UpdateL1InfoTree log must carry: + // topics[0] (event signature) + the indexed mainnetExitRoot and rollupExitRoot. + updateL1InfoTreeMinTopics = 3 +) + +// verifyBatchesTrustedAggregatorTopic is keccak256 of the event signature. The RollupManager +// emits it on L1 when a rollup's batches are verified (the certificate is settled on L1): +// VerifyBatchesTrustedAggregator(uint32 indexed rollupID, uint64 numBatch, bytes32 stateRoot, +// bytes32 exitRoot, address indexed aggregator). +var verifyBatchesTrustedAggregatorTopic = crypto.Keccak256Hash( + []byte("VerifyBatchesTrustedAggregator(uint32,uint64,bytes32,bytes32,address)"), +) + +// L1 GlobalExitRoot contract events emitted alongside the certificate's L1 settlement. +var ( + // UpdateL1InfoTree(bytes32 indexed mainnetExitRoot, bytes32 indexed rollupExitRoot). + updateL1InfoTreeTopic = crypto.Keccak256Hash([]byte("UpdateL1InfoTree(bytes32,bytes32)")) + // UpdateL1InfoTreeV2(bytes32 currentL1InfoRoot, uint32 indexed leafCount, uint256 blockhash, + // uint64 minTimestamp). leafCount is indexed (topics[1]); the rest is in data. + updateL1InfoTreeV2TopicWait = crypto.Keccak256Hash( + []byte("UpdateL1InfoTreeV2(bytes32,uint32,uint256,uint64)")) +) + +// RunStepWait waits for the submitted certificate to reach a final state. It polls the +// agglayer for the certificate header by hash with GetCertificateHeader — which always +// returns the current status — until it is Settled (success) or InError (error). +// +// Requires options.agglayerClient.grpc.url. +func RunStepWait(ctx context.Context, cfg *Config, submitResult *StepSubmitResult) (*StepWaitResult, error) { + log.Info("═══════════════════════════════════════════") + log.Info(" STEP WAIT - Wait for certificate settlement") + log.Info("═══════════════════════════════════════════") + + agglayerClientCfg := cfg.Options.AgglayerClient + if agglayerClientCfg.GRPC == nil || agglayerClientCfg.GRPC.URL == "" { + return nil, fmt.Errorf("agglayerClient.grpc.url is required for step wait") + } + + client, err := agglayer.NewAgglayerClient(agglayerClientCfg, log.GetDefaultLogger()) + if err != nil { + return nil, fmt.Errorf("create agglayer gRPC client: %w", err) + } + + return runStepWait(ctx, cfg, client, submitResult) +} + +// runStepWait is the client-injectable core of RunStepWait (tests pass an agglayer client mock in +// place of the real gRPC client). It polls the certificate until it is final, errors if it settled +// InError, and then confirms the settlement on L1. +func runStepWait( + ctx context.Context, cfg *Config, client agglayer.AgglayerClientInterface, submitResult *StepSubmitResult, +) (*StepWaitResult, error) { + certHash := submitResult.CertificateHash + + start := time.Now() + result := &StepWaitResult{CertificateHash: certHash} + + // Poll the submitted certificate by hash until it reaches a final state. + log.Infof("Polling submitted certificate %s every %s...", certHash.Hex(), waitPollInterval) + finalHeader, err := waitUntilFinal(ctx, client, certHash) + if err != nil { + return nil, err + } + + elapsed := time.Since(start) + result.FinalStatus = finalHeader.Status + result.SettlementTxHash = finalHeader.SettlementTxHash + result.ElapsedSeconds = elapsed.Seconds() + + if !finalHeader.Status.IsSettled() { + errMsg := "" + if finalHeader.Error != nil { + errMsg = finalHeader.Error.Error() + } + log.Errorf("Certificate entered InError after %s: %s", elapsed.Round(time.Second), errMsg) + return nil, fmt.Errorf("certificate %s is in error after %s: %s", + certHash.Hex(), elapsed.Round(time.Second), errMsg) + } + + log.Infof("Certificate settled in %s", elapsed.Round(time.Second)) + if finalHeader.SettlementTxHash != nil { + log.Infof("Settlement tx: %s", finalHeader.SettlementTxHash.Hex()) + } + + // Confirm the settlement on L1: the RollupManager must have emitted a + // VerifyBatchesTrustedAggregator event for our rollupID with this certificate's exit root. + if err := confirmVerifyBatchesOnL1(ctx, cfg, submitResult, finalHeader.NewLocalExitRoot, result); err != nil { + return nil, err + } + + log.Info("STEP WAIT complete") + return result, nil +} + +// waitUntilFinal polls GetCertificateHeader every waitPollInterval until the certificate +// reaches a closed state (Settled or InError) and returns the final header. +func waitUntilFinal( + ctx context.Context, client agglayer.AgglayerClientInterface, certHash common.Hash, +) (*agglayertypes.CertificateHeader, error) { + var lastStatus agglayertypes.CertificateStatus = -1 + start := time.Now() + + for { + select { + case <-ctx.Done(): + return nil, fmt.Errorf("context cancelled after %s: %w", time.Since(start).Round(time.Second), ctx.Err()) + case <-time.After(waitPollInterval): + } + + header, err := client.GetCertificateHeader(ctx, certHash) + if err != nil { + log.Warnf("GetCertificateHeader(%s) error (will retry): %v", certHash.Hex(), err) + continue + } + + if header.Status != lastStatus { + log.Infof("[%s] status: %s (elapsed: %s)", + certHash.Hex()[:10], header.Status, time.Since(start).Round(time.Second)) + lastStatus = header.Status + } + + if header.Status.IsClosed() { + return header, nil + } + } +} + +// confirmVerifyBatchesOnL1 confirms the just-settled certificate also landed on L1: it scans the +// RollupManager for the VerifyBatchesTrustedAggregator event between the L1 block captured right +// before submission and the finalized block, matching the rollupID (cfg.L2NetworkID) and the +// certificate's NewLocalExitRoot. On success it records the L1 block and tx hash in result. +// +// The RollupManager address is taken from cfg.RollupManagerAddress when set, otherwise resolved +// on-chain from the consensus contract (cfg.SovereignRollupAddr.rollupManager()). It errors when +// l1RpcUrl is unset or neither the RollupManager nor the sovereign-rollup address is available. +func confirmVerifyBatchesOnL1( + ctx context.Context, cfg *Config, submitResult *StepSubmitResult, exitRoot common.Hash, result *StepWaitResult, +) error { + if cfg.L1RPCURL == "" { + return fmt.Errorf("l1RpcUrl is required to confirm the certificate's L1 settlement " + + "(VerifyBatchesTrustedAggregator)") + } + + rollupManagerAddr, err := resolveRollupManagerAddress(ctx, cfg) + if err != nil { + return fmt.Errorf("resolve rollupManager address: %w", err) + } + if rollupManagerAddr == (common.Address{}) { + return fmt.Errorf("cannot confirm the certificate's L1 settlement: set rollupManagerAddress, " + + "or sovereignRollupAddr so it can be resolved on-chain") + } + + fromBlock := submitResult.L1LatestBlockBeforeSubmittingCertificate + rollupID := cfg.L2NetworkID + log.Infof("Confirming L1 settlement: scanning RollupManager %s for VerifyBatchesTrustedAggregator "+ + "(rollupID=%d, exitRoot=%s) from L1 block %d to finalized...", + rollupManagerAddr.Hex(), rollupID, exitRoot.Hex(), fromBlock) + + blockNumber, txHash, err := waitForVerifyBatchesOnL1(ctx, cfg, rollupManagerAddr, fromBlock, rollupID, exitRoot) + if err != nil { + return fmt.Errorf("confirm VerifyBatchesTrustedAggregator on L1: %w", err) + } + + result.VerifyBatchesL1Block = blockNumber + result.VerifyBatchesTxHash = &txHash + log.Infof("✅ Certificate settled on L1: VerifyBatchesTrustedAggregator found at block %d (tx: %s)", + blockNumber, txHash.Hex()) + + // The same L1 block carries the GlobalExitRoot contract's L1 info tree updates. + if err := fetchGERUpdatesInBlock(ctx, cfg, blockNumber, result); err != nil { + return fmt.Errorf("fetch L1 info tree updates at block %d: %w", blockNumber, err) + } + return nil +} + +// fetchGERUpdatesInBlock reads the L1 GlobalExitRoot contract's UpdateL1InfoTree and +// UpdateL1InfoTreeV2 events from the given L1 block (where VerifyBatchesTrustedAggregator landed) +// and stores the last occurrence of each on result. Both events are emitted when the global exit +// root is updated as part of the settlement, so each must be present — a missing event is an error. +func fetchGERUpdatesInBlock(ctx context.Context, cfg *Config, blockNumber uint64, result *StepWaitResult) error { + if cfg.L1GlobalExitRootAddress == (common.Address{}) { + return fmt.Errorf("l1GlobalExitRootAddress is required to read the L1 info tree updates") + } + + v1, err := fetchLastUpdateL1InfoTree(ctx, cfg, blockNumber) + if err != nil { + return err + } + v2, err := fetchLastUpdateL1InfoTreeV2(ctx, cfg, blockNumber) + if err != nil { + return err + } + + result.UpdateL1InfoTree = v1 + result.UpdateL1InfoTreeV2 = v2 + log.Infof("UpdateL1InfoTree at block %d (tx: %s): mainnetExitRoot=%s rollupExitRoot=%s", + blockNumber, v1.TxHash.Hex(), v1.MainnetExitRoot.Hex(), v1.RollupExitRoot.Hex()) + log.Infof("UpdateL1InfoTreeV2 at block %d (tx: %s): leafCount=%d currentL1InfoRoot=%s minTimestamp=%d", + blockNumber, v2.TxHash.Hex(), v2.LeafCount, v2.CurrentL1InfoRoot.Hex(), v2.MinTimestamp) + return nil +} + +// rawLog is the subset of an eth_getLogs entry we decode for the GlobalExitRoot events. +type rawLog struct { + Topics []string `json:"topics"` + Data string `json:"data"` + TxHash string `json:"transactionHash"` +} + +// fetchLogsInBlock returns every log emitted by addr with the given topic[0] in a single L1 block. +func fetchLogsInBlock( + ctx context.Context, rpcURL string, addr common.Address, topic common.Hash, blockNumber uint64, +) ([]rawLog, error) { + tag := toBlockTag(blockNumber) + result, err := singleRPC(ctx, rpcURL, "eth_getLogs", []any{ + map[string]any{ + "address": addr.Hex(), + "topics": []string{topic.Hex()}, + "fromBlock": tag, + "toBlock": tag, + }, + }, defaultRetries) + if err != nil { + return nil, err + } + var logs []rawLog + if err := json.Unmarshal(result, &logs); err != nil { + return nil, fmt.Errorf("unmarshal logs: %w", err) + } + return logs, nil +} + +// fetchLastUpdateL1InfoTree returns the last UpdateL1InfoTree event in blockNumber. +func fetchLastUpdateL1InfoTree(ctx context.Context, cfg *Config, blockNumber uint64) (*L1InfoTreeUpdate, error) { + logs, err := fetchLogsInBlock(ctx, cfg.L1RPCURL, cfg.L1GlobalExitRootAddress, updateL1InfoTreeTopic, blockNumber) + if err != nil { + return nil, fmt.Errorf("query UpdateL1InfoTree: %w", err) + } + if len(logs) == 0 { + return nil, fmt.Errorf("no UpdateL1InfoTree event found in block %d", blockNumber) + } + + // mainnetExitRoot and rollupExitRoot are both indexed (topics[1], topics[2]). + last := logs[len(logs)-1] + if len(last.Topics) < updateL1InfoTreeMinTopics { + return nil, fmt.Errorf("UpdateL1InfoTree log has only %d topics", len(last.Topics)) + } + return &L1InfoTreeUpdate{ + MainnetExitRoot: common.HexToHash(last.Topics[1]), + RollupExitRoot: common.HexToHash(last.Topics[2]), + TxHash: common.HexToHash(last.TxHash), + }, nil +} + +// fetchLastUpdateL1InfoTreeV2 returns the last UpdateL1InfoTreeV2 event in blockNumber. +func fetchLastUpdateL1InfoTreeV2(ctx context.Context, cfg *Config, blockNumber uint64) (*L1InfoTreeV2Update, error) { + logs, err := fetchLogsInBlock( + ctx, cfg.L1RPCURL, cfg.L1GlobalExitRootAddress, updateL1InfoTreeV2TopicWait, blockNumber) + if err != nil { + return nil, fmt.Errorf("query UpdateL1InfoTreeV2: %w", err) + } + if len(logs) == 0 { + return nil, fmt.Errorf("no UpdateL1InfoTreeV2 event found in block %d", blockNumber) + } + + last := logs[len(logs)-1] + if len(last.Topics) < minTopicsForLeaf { + return nil, fmt.Errorf("UpdateL1InfoTreeV2 log has only %d topics", len(last.Topics)) + } + // leafCount is indexed (topics[1]); data = currentL1InfoRoot[0:32] ++ blockhash[32:64] ++ + // minTimestamp[64:96]. + leafCount, err := safeUint32(new(big.Int).SetBytes(common.FromHex(last.Topics[1]))) + if err != nil { + return nil, fmt.Errorf("decode UpdateL1InfoTreeV2 leafCount: %w", err) + } + data := common.FromHex(last.Data) + if len(data) < verifyBatchesDataLen { + return nil, fmt.Errorf("UpdateL1InfoTreeV2 log has %d data bytes, expected %d", + len(data), verifyBatchesDataLen) + } + return &L1InfoTreeV2Update{ + CurrentL1InfoRoot: common.BytesToHash(data[0:32]), + LeafCount: leafCount, + Blockhash: common.BytesToHash(data[32:64]), + MinTimestamp: new(big.Int).SetBytes(data[64:96]).Uint64(), + TxHash: common.HexToHash(last.TxHash), + }, nil +} + +// resolveRollupManagerAddress returns cfg.RollupManagerAddress when set, otherwise reads it from the +// consensus contract via cfg.SovereignRollupAddr.rollupManager() (PolygonConsensusBase) on L1. +// Returns the zero address (no error) when neither is available. +func resolveRollupManagerAddress(ctx context.Context, cfg *Config) (common.Address, error) { + if cfg.RollupManagerAddress != (common.Address{}) { + return cfg.RollupManagerAddress, nil + } + if cfg.SovereignRollupAddr == (common.Address{}) { + return common.Address{}, nil + } + + result, err := singleRPC(ctx, cfg.L1RPCURL, "eth_call", []any{ + map[string]string{"to": cfg.SovereignRollupAddr.Hex(), "data": rollupManagerSelector}, + "latest", + }, defaultRetries) + if err != nil { + return common.Address{}, fmt.Errorf("call rollupManager() on %s: %w", cfg.SovereignRollupAddr.Hex(), err) + } + + var hex string + if err := json.Unmarshal(result, &hex); err != nil { + return common.Address{}, fmt.Errorf("parse rollupManager() result: %w", err) + } + addr := common.HexToAddress(hex) + if addr == (common.Address{}) { + return common.Address{}, fmt.Errorf("rollupManager() on %s returned the zero address", cfg.SovereignRollupAddr.Hex()) + } + log.Infof("Resolved rollupManager %s from sovereignRollupAddr %s", addr.Hex(), cfg.SovereignRollupAddr.Hex()) + return addr, nil +} + +// waitForVerifyBatchesOnL1 polls L1 until the RollupManager's VerifyBatchesTrustedAggregator event +// for rollupID with the given exitRoot appears in [fromBlock, finalized]. The settlement tx may not +// be finalized yet when the certificate first reports Settled, so it re-resolves the finalized block +// and re-scans every waitPollInterval until the event is found or the context is cancelled. +func waitForVerifyBatchesOnL1( + ctx context.Context, cfg *Config, rollupManagerAddr common.Address, + fromBlock uint64, rollupID uint32, exitRoot common.Hash, +) (blockNumber uint64, txHash common.Hash, err error) { + chunkSize := uint64(cfg.Options.BlockRange) + if chunkSize == 0 { + chunkSize = defaultBlockRange + } + start := time.Now() + + for { + toBlock, ferr := resolveFinalizedBlock(ctx, cfg.L1RPCURL) + if ferr != nil { + log.Warnf("resolve finalized L1 block error (will retry): %v", ferr) + } else if toBlock >= fromBlock { + block, tx, found, serr := scanVerifyBatches( + ctx, cfg.L1RPCURL, rollupManagerAddr, rollupID, exitRoot, fromBlock, toBlock, chunkSize) + if serr != nil { + log.Warnf("scan VerifyBatchesTrustedAggregator [%d-%d] error (will retry): %v", fromBlock, toBlock, serr) + } else if found { + return block, tx, nil + } else { + log.Infof("VerifyBatchesTrustedAggregator not found yet in [%d-%d] (elapsed: %s), waiting...", + fromBlock, toBlock, time.Since(start).Round(time.Second)) + } + } + + select { + case <-ctx.Done(): + return 0, common.Hash{}, fmt.Errorf( + "context cancelled after %s waiting for VerifyBatchesTrustedAggregator: %w", + time.Since(start).Round(time.Second), ctx.Err()) + case <-time.After(waitPollInterval): + } + } +} + +// scanVerifyBatches scans [fromBlock, toBlock] forward in chunkSize-sized ranges for the +// VerifyBatchesTrustedAggregator event filtered by rollupID, returning the first log whose exitRoot +// matches the given one. +func scanVerifyBatches( + ctx context.Context, rpcURL string, contractAddr common.Address, rollupID uint32, exitRoot common.Hash, + fromBlock, toBlock, chunkSize uint64, +) (blockNumber uint64, txHash common.Hash, found bool, err error) { + for start := fromBlock; start <= toBlock; start += chunkSize { + end := min(start+chunkSize-1, toBlock) + + block, tx, ok, qerr := queryVerifyBatches(ctx, rpcURL, contractAddr, rollupID, exitRoot, start, end) + if qerr != nil { + return 0, common.Hash{}, false, qerr + } + if ok { + return block, tx, true, nil + } + } + return 0, common.Hash{}, false, nil +} + +// queryVerifyBatches fetches VerifyBatchesTrustedAggregator logs for the given rollupID in +// [fromBlock, toBlock] and returns the first one whose exitRoot (data[64:96]) matches exitRoot. +func queryVerifyBatches( + ctx context.Context, rpcURL string, contractAddr common.Address, rollupID uint32, exitRoot common.Hash, + fromBlock, toBlock uint64, +) (blockNumber uint64, txHash common.Hash, found bool, err error) { + // topics[1] is the indexed rollupID, ABI-encoded as a 32-byte big-endian value. + rollupIDTopic := common.BigToHash(new(big.Int).SetUint64(uint64(rollupID))) + result, err := singleRPC(ctx, rpcURL, "eth_getLogs", []any{ + map[string]any{ + "address": contractAddr.Hex(), + "topics": []string{verifyBatchesTrustedAggregatorTopic.Hex(), rollupIDTopic.Hex()}, + "fromBlock": toBlockTag(fromBlock), + "toBlock": toBlockTag(toBlock), + }, + }, defaultRetries) + if err != nil { + return 0, common.Hash{}, false, err + } + + var logs []struct { + BlockNumber string `json:"blockNumber"` + TxHash string `json:"transactionHash"` + Data string `json:"data"` + } + if err := json.Unmarshal(result, &logs); err != nil { + return 0, common.Hash{}, false, fmt.Errorf("unmarshal VerifyBatchesTrustedAggregator logs: %w", err) + } + + for _, l := range logs { + data := common.FromHex(l.Data) + if len(data) < verifyBatchesDataLen { + log.Warnf("VerifyBatchesTrustedAggregator log has %d data bytes, expected %d — skipping", + len(data), verifyBatchesDataLen) + continue + } + // data layout: [0:32] numBatch, [32:64] stateRoot, [64:96] exitRoot. + if common.BytesToHash(data[64:96]) == exitRoot { + return hexToUint64(l.BlockNumber), common.HexToHash(l.TxHash), true, nil + } + } + return 0, common.Hash{}, false, nil +} + +// resolveFinalizedBlock returns the number of the latest finalized L1 block. +func resolveFinalizedBlock(ctx context.Context, rpcURL string) (uint64, error) { + result, err := singleRPC(ctx, rpcURL, "eth_getBlockByNumber", []any{"finalized", false}, defaultRetries) + if err != nil { + return 0, err + } + var block struct { + Number string `json:"number"` + } + if err := json.Unmarshal(result, &block); err != nil { + return 0, fmt.Errorf("parse finalized block: %w", err) + } + if block.Number == "" { + return 0, fmt.Errorf("finalized block not available") + } + return hexToUint64(block.Number), nil +} diff --git a/tools/exit_certificate/step_wait_confirm_test.go b/tools/exit_certificate/step_wait_confirm_test.go new file mode 100644 index 000000000..a796b7764 --- /dev/null +++ b/tools/exit_certificate/step_wait_confirm_test.go @@ -0,0 +1,79 @@ +package exit_certificate + +import ( + "context" + "encoding/json" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +// TestConfirmVerifyBatchesOnL1Success drives the full L1 settlement-confirmation flow against a stub: +// it resolves the finalized block, finds the VerifyBatchesTrustedAggregator event matching the +// rollupID + exit root, then reads the accompanying L1 info tree updates in that block. This exercises +// waitForVerifyBatchesOnL1 / scanVerifyBatches / queryVerifyBatches / resolveFinalizedBlock and the +// fetchGERUpdatesInBlock helpers end-to-end. +func TestConfirmVerifyBatchesOnL1Success(t *testing.T) { + t.Parallel() + exitRoot := common.HexToHash("0xexit") + verifyTx := common.HexToHash("0xverify") + v1Tx := common.HexToHash("0xv1") + v2Tx := common.HexToHash("0xv2") + mainnetExitRoot := common.HexToHash("0x1111") + rollupExitRoot := common.HexToHash("0x2222") + + srv := newRPCStub(t, func(method string, params []any) (json.RawMessage, *jsonRPCError) { + switch method { + case rpcMethodEthGetBlockByNumber: + return json.RawMessage(`{"number":"0xa"}`), nil // finalized block 10 + case rpcMethodEthGetLogs: + switch getLogsTopic0(t, params) { + case verifyBatchesTrustedAggregatorTopic.Hex(): + return logsResult(t, 7, verifyTx, verifyBatchesData(1, common.HexToHash("0xstate"), exitRoot)), nil + case updateL1InfoTreeTopic.Hex(): + return topicLogsResult(t, v1Tx, + []common.Hash{updateL1InfoTreeTopic, mainnetExitRoot, rollupExitRoot}, nil), nil + case updateL1InfoTreeV2TopicWait.Hex(): + return topicLogsResult(t, v2Tx, + []common.Hash{updateL1InfoTreeV2TopicWait, common.BytesToHash([]byte{0x05})}, + v2Data(common.HexToHash("0xroot"), common.HexToHash("0xbh"), 12345)), nil + } + return json.RawMessage(`[]`), nil + default: + return quoted("0x"), nil + } + }) + + cfg := &Config{ + L1RPCURL: srv.URL, + RollupManagerAddress: common.HexToAddress("0x3333333333333333333333333333333333333333"), + L1GlobalExitRootAddress: common.HexToAddress("0x4444444444444444444444444444444444444444"), + L2NetworkID: 1, + Options: Options{BlockRange: 5000}, + } + result := &StepWaitResult{} + err := confirmVerifyBatchesOnL1(context.Background(), cfg, + &StepSubmitResult{L1LatestBlockBeforeSubmittingCertificate: 0}, exitRoot, result) + require.NoError(t, err) + require.Equal(t, uint64(7), result.VerifyBatchesL1Block) + require.Equal(t, &verifyTx, result.VerifyBatchesTxHash) + require.NotNil(t, result.UpdateL1InfoTree) + require.Equal(t, mainnetExitRoot, result.UpdateL1InfoTree.MainnetExitRoot) + require.NotNil(t, result.UpdateL1InfoTreeV2) + require.Equal(t, uint32(5), result.UpdateL1InfoTreeV2.LeafCount) +} + +func TestConfirmVerifyBatchesOnL1RequiresL1RPC(t *testing.T) { + t.Parallel() + err := confirmVerifyBatchesOnL1(context.Background(), &Config{}, &StepSubmitResult{}, common.Hash{}, &StepWaitResult{}) + require.ErrorContains(t, err, "l1RpcUrl is required") +} + +func TestConfirmVerifyBatchesOnL1NoRollupManager(t *testing.T) { + t.Parallel() + // L1 RPC set but neither rollupManagerAddress nor sovereignRollupAddr → cannot resolve. + cfg := &Config{L1RPCURL: "http://127.0.0.1:1"} + err := confirmVerifyBatchesOnL1(context.Background(), cfg, &StepSubmitResult{}, common.Hash{}, &StepWaitResult{}) + require.ErrorContains(t, err, "set rollupManagerAddress") +} diff --git a/tools/exit_certificate/step_wait_runstep_test.go b/tools/exit_certificate/step_wait_runstep_test.go new file mode 100644 index 000000000..654a5907b --- /dev/null +++ b/tools/exit_certificate/step_wait_runstep_test.go @@ -0,0 +1,63 @@ +package exit_certificate + +import ( + "context" + "errors" + "testing" + + "github.com/agglayer/aggkit/agglayer/mocks" + agglayertypes "github.com/agglayer/aggkit/agglayer/types" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestWaitUntilFinalSettled(t *testing.T) { + t.Parallel() + certHash := common.HexToHash("0xc0ffee") + settlementTx := common.HexToHash("0x5e771e") + + client := mocks.NewAgglayerClientMock(t) + // First poll returns a transient error (retried), second returns Settled. + client.EXPECT().GetCertificateHeader(mock.Anything, certHash). + Return(nil, errors.New("transient")).Once() + client.EXPECT().GetCertificateHeader(mock.Anything, certHash). + Return(&agglayertypes.CertificateHeader{ + Status: agglayertypes.Settled, + NewLocalExitRoot: common.HexToHash("0xabc"), + SettlementTxHash: &settlementTx, + }, nil) + + header, err := waitUntilFinal(context.Background(), client, certHash) + require.NoError(t, err) + require.True(t, header.Status.IsSettled()) + require.Equal(t, &settlementTx, header.SettlementTxHash) +} + +func TestWaitUntilFinalContextCancelled(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithCancel(context.Background()) + cancel() // already cancelled → the select returns immediately + + client := mocks.NewAgglayerClientMock(t) + _, err := waitUntilFinal(ctx, client, common.HexToHash("0x1")) + require.ErrorIs(t, err, context.Canceled) +} + +func TestRunStepWaitRequiresGRPCURL(t *testing.T) { + t.Parallel() + _, err := RunStepWait(context.Background(), &Config{}, &StepSubmitResult{}) + require.ErrorContains(t, err, "agglayerClient.grpc.url is required") +} + +func TestRunStepHRequiresGRPCURL(t *testing.T) { + t.Parallel() + _, err := RunStepH(context.Background(), &Config{}, nil) + require.ErrorContains(t, err, "agglayerClient.grpc.url is required") +} + +func TestRunStepSubmitRequiresGRPCURL(t *testing.T) { + t.Parallel() + _, err := RunStepSubmit(context.Background(), &Config{}, &agglayertypes.Certificate{}) + require.ErrorContains(t, err, "agglayerClient.grpc.url is required") +} diff --git a/tools/exit_certificate/step_wait_runstepwait_test.go b/tools/exit_certificate/step_wait_runstepwait_test.go new file mode 100644 index 000000000..ddb140e3d --- /dev/null +++ b/tools/exit_certificate/step_wait_runstepwait_test.go @@ -0,0 +1,81 @@ +package exit_certificate + +import ( + "context" + "encoding/json" + "testing" + + "github.com/agglayer/aggkit/agglayer/mocks" + agglayertypes "github.com/agglayer/aggkit/agglayer/types" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +// TestRunStepWaitSuccess drives runStepWait end-to-end: the certificate settles (via the mock client) +// and the L1 settlement is confirmed against an L1 RPC stub serving the VerifyBatchesTrustedAggregator +// event and the accompanying L1 info tree updates. +func TestRunStepWaitSuccess(t *testing.T) { + t.Parallel() + certHash := common.HexToHash("0xc0ffee") + exitRoot := common.HexToHash("0xexit") + settlementTx := common.HexToHash("0x5e771e") + verifyTx := common.HexToHash("0xverify") + + l1 := newRPCStub(t, func(method string, params []any) (json.RawMessage, *jsonRPCError) { + switch method { + case rpcMethodEthGetBlockByNumber: + return json.RawMessage(`{"number":"0xa"}`), nil + case rpcMethodEthGetLogs: + switch getLogsTopic0(t, params) { + case verifyBatchesTrustedAggregatorTopic.Hex(): + return logsResult(t, 7, verifyTx, verifyBatchesData(1, common.HexToHash("0xstate"), exitRoot)), nil + case updateL1InfoTreeTopic.Hex(): + return topicLogsResult(t, common.HexToHash("0xv1"), + []common.Hash{updateL1InfoTreeTopic, common.HexToHash("0x1111"), common.HexToHash("0x2222")}, nil), nil + case updateL1InfoTreeV2TopicWait.Hex(): + return topicLogsResult(t, common.HexToHash("0xv2"), + []common.Hash{updateL1InfoTreeV2TopicWait, common.BytesToHash([]byte{0x05})}, + v2Data(common.HexToHash("0xroot"), common.HexToHash("0xbh"), 12345)), nil + } + return json.RawMessage(`[]`), nil + default: + return quoted("0x"), nil + } + }) + + client := mocks.NewAgglayerClientMock(t) + client.EXPECT().GetCertificateHeader(mock.Anything, certHash).Return(&agglayertypes.CertificateHeader{ + Status: agglayertypes.Settled, + NewLocalExitRoot: exitRoot, + SettlementTxHash: &settlementTx, + }, nil) + + cfg := &Config{ + L1RPCURL: l1.URL, + RollupManagerAddress: common.HexToAddress("0x3333333333333333333333333333333333333333"), + L1GlobalExitRootAddress: common.HexToAddress("0x4444444444444444444444444444444444444444"), + L2NetworkID: 1, + Options: Options{BlockRange: 5000}, + } + + res, err := runStepWait(context.Background(), cfg, client, &StepSubmitResult{CertificateHash: certHash}) + require.NoError(t, err) + require.True(t, res.FinalStatus.IsSettled()) + require.Equal(t, &settlementTx, res.SettlementTxHash) + require.Equal(t, uint64(7), res.VerifyBatchesL1Block) + require.NotNil(t, res.UpdateL1InfoTree) +} + +func TestRunStepWaitInError(t *testing.T) { + t.Parallel() + certHash := common.HexToHash("0xbad") + + client := mocks.NewAgglayerClientMock(t) + client.EXPECT().GetCertificateHeader(mock.Anything, certHash).Return(&agglayertypes.CertificateHeader{ + Status: agglayertypes.InError, + }, nil) + + _, err := runStepWait(context.Background(), &Config{L2NetworkID: 1}, client, &StepSubmitResult{CertificateHash: certHash}) + require.ErrorContains(t, err, "is in error") +} diff --git a/tools/exit_certificate/step_wait_verifybatches_test.go b/tools/exit_certificate/step_wait_verifybatches_test.go new file mode 100644 index 000000000..23117ba4c --- /dev/null +++ b/tools/exit_certificate/step_wait_verifybatches_test.go @@ -0,0 +1,355 @@ +package exit_certificate + +import ( + "context" + "encoding/json" + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +// verifyBatchesData builds the ABI-encoded data of a VerifyBatchesTrustedAggregator log: +// numBatch (uint64) + stateRoot (bytes32) + exitRoot (bytes32), each padded to 32 bytes. +func verifyBatchesData(numBatch uint64, stateRoot, exitRoot common.Hash) []byte { + data := make([]byte, verifyBatchesDataLen) + big.NewInt(0).SetUint64(numBatch).FillBytes(data[0:32]) + copy(data[32:64], stateRoot.Bytes()) + copy(data[64:96], exitRoot.Bytes()) + return data +} + +// logsResult marshals a single eth_getLogs entry as the JSON array an RPC node returns. +func logsResult(t *testing.T, blockNumber uint64, txHash common.Hash, data []byte) json.RawMessage { + t.Helper() + out, err := json.Marshal([]map[string]string{{ + "blockNumber": toBlockTag(blockNumber), + "transactionHash": txHash.Hex(), + "data": "0x" + common.Bytes2Hex(data), + }}) + require.NoError(t, err) + return out +} + +// topicLogsResult marshals an eth_getLogs array with explicit topics and data (for the GER events). +func topicLogsResult(t *testing.T, txHash common.Hash, topics []common.Hash, data []byte) json.RawMessage { + t.Helper() + hexTopics := make([]string, len(topics)) + for i, tp := range topics { + hexTopics[i] = tp.Hex() + } + out, err := json.Marshal([]map[string]any{{ + "transactionHash": txHash.Hex(), + "topics": hexTopics, + "data": "0x" + common.Bytes2Hex(data), + }}) + require.NoError(t, err) + return out +} + +// getLogsTopic0 returns the topics[0] filter of an eth_getLogs request. +func getLogsTopic0(t *testing.T, params []any) string { + t.Helper() + filter, ok := params[0].(map[string]any) + require.True(t, ok) + topics, ok := filter["topics"].([]any) + require.True(t, ok) + topic0, ok := topics[0].(string) + require.True(t, ok) + return topic0 +} + +// v2Data builds the data of an UpdateL1InfoTreeV2 log: currentL1InfoRoot ++ blockhash ++ minTimestamp. +func v2Data(currentL1InfoRoot, blockhash common.Hash, minTimestamp uint64) []byte { + data := make([]byte, 96) + copy(data[0:32], currentL1InfoRoot.Bytes()) + copy(data[32:64], blockhash.Bytes()) + big.NewInt(0).SetUint64(minTimestamp).FillBytes(data[64:96]) + return data +} + +func TestResolveRollupManagerAddress(t *testing.T) { + t.Parallel() + rollupManager := common.HexToAddress("0x5132A183E9F3CB7C848b0AAC5Ae0c4f0491B7aB2") + sovereign := common.HexToAddress("0xA13Ddb14437A8F34897131367ad3ca78416d6bCa") + + t.Run("configured address short-circuits without RPC", func(t *testing.T) { + t.Parallel() + srv := newRPCStub(t, func(string, []any) (json.RawMessage, *jsonRPCError) { + t.Fatal("no RPC call expected when rollupManagerAddress is set") + return nil, nil + }) + cfg := &Config{L1RPCURL: srv.URL, RollupManagerAddress: rollupManager, SovereignRollupAddr: sovereign} + got, err := resolveRollupManagerAddress(context.Background(), cfg) + require.NoError(t, err) + require.Equal(t, rollupManager, got) + }) + + t.Run("resolves from sovereignRollupAddr", func(t *testing.T) { + t.Parallel() + srv := newRPCStub(t, func(method string, params []any) (json.RawMessage, *jsonRPCError) { + require.Equal(t, rpcMethodEthCall, method) + call, ok := params[0].(map[string]any) + require.True(t, ok) + require.Equal(t, sovereign.Hex(), call["to"]) + require.Equal(t, rollupManagerSelector, call["data"]) + return hexResult(common.LeftPadBytes(rollupManager.Bytes(), 32)), nil + }) + cfg := &Config{L1RPCURL: srv.URL, SovereignRollupAddr: sovereign} + got, err := resolveRollupManagerAddress(context.Background(), cfg) + require.NoError(t, err) + require.Equal(t, rollupManager, got) + }) + + t.Run("neither address set returns zero without error", func(t *testing.T) { + t.Parallel() + cfg := &Config{L1RPCURL: "http://unused"} + got, err := resolveRollupManagerAddress(context.Background(), cfg) + require.NoError(t, err) + require.Equal(t, common.Address{}, got) + }) + + t.Run("rollupManager() returning zero is an error", func(t *testing.T) { + t.Parallel() + srv := newRPCStub(t, func(string, []any) (json.RawMessage, *jsonRPCError) { + return hexResult(make([]byte, 32)), nil + }) + cfg := &Config{L1RPCURL: srv.URL, SovereignRollupAddr: sovereign} + _, err := resolveRollupManagerAddress(context.Background(), cfg) + require.Error(t, err) + }) +} + +func TestResolveFinalizedBlock(t *testing.T) { + t.Parallel() + + t.Run("success", func(t *testing.T) { + t.Parallel() + srv := newRPCStub(t, func(method string, params []any) (json.RawMessage, *jsonRPCError) { + require.Equal(t, "eth_getBlockByNumber", method) + require.Equal(t, "finalized", params[0]) + return json.RawMessage(`{"number":"0x10"}`), nil + }) + n, err := resolveFinalizedBlock(context.Background(), srv.URL) + require.NoError(t, err) + require.Equal(t, uint64(16), n) + }) + + t.Run("null block is an error", func(t *testing.T) { + t.Parallel() + srv := newRPCStub(t, func(string, []any) (json.RawMessage, *jsonRPCError) { + return json.RawMessage(`{}`), nil + }) + _, err := resolveFinalizedBlock(context.Background(), srv.URL) + require.Error(t, err) + }) +} + +func TestQueryVerifyBatches(t *testing.T) { + t.Parallel() + contract := common.HexToAddress("0x5132A183E9F3CB7C848b0AAC5Ae0c4f0491B7aB2") + exitRoot := common.HexToHash("0xabc123") + txHash := common.HexToHash("0xdead") + + t.Run("matching exit root is found", func(t *testing.T) { + t.Parallel() + srv := newRPCStub(t, func(method string, params []any) (json.RawMessage, *jsonRPCError) { + require.Equal(t, rpcMethodEthGetLogs, method) + filter, ok := params[0].(map[string]any) + require.True(t, ok) + require.Equal(t, contract.Hex(), filter["address"]) + topics, ok := filter["topics"].([]any) + require.True(t, ok) + require.Equal(t, verifyBatchesTrustedAggregatorTopic.Hex(), topics[0]) + // topics[1] is the indexed rollupID (5) as a 32-byte value. + require.Equal(t, common.BigToHash(big.NewInt(5)).Hex(), topics[1]) + return logsResult(t, 42, txHash, verifyBatchesData(7, common.Hash{}, exitRoot)), nil + }) + block, tx, found, err := queryVerifyBatches( + context.Background(), srv.URL, contract, 5, exitRoot, 0, 100) + require.NoError(t, err) + require.True(t, found) + require.Equal(t, uint64(42), block) + require.Equal(t, txHash, tx) + }) + + t.Run("non-matching exit root is not found", func(t *testing.T) { + t.Parallel() + srv := newRPCStub(t, func(string, []any) (json.RawMessage, *jsonRPCError) { + return logsResult(t, 42, txHash, verifyBatchesData(7, common.Hash{}, common.HexToHash("0x999"))), nil + }) + _, _, found, err := queryVerifyBatches( + context.Background(), srv.URL, contract, 5, exitRoot, 0, 100) + require.NoError(t, err) + require.False(t, found) + }) + + t.Run("no logs is not found", func(t *testing.T) { + t.Parallel() + srv := newRPCStub(t, func(string, []any) (json.RawMessage, *jsonRPCError) { + return json.RawMessage(`[]`), nil + }) + _, _, found, err := queryVerifyBatches( + context.Background(), srv.URL, contract, 5, exitRoot, 0, 100) + require.NoError(t, err) + require.False(t, found) + }) +} + +func TestConfirmVerifyBatchesOnL1(t *testing.T) { + t.Parallel() + sovereign := common.HexToAddress("0xA13Ddb14437A8F34897131367ad3ca78416d6bCa") + rollupManager := common.HexToAddress("0x5132A183E9F3CB7C848b0AAC5Ae0c4f0491B7aB2") + exitRoot := common.HexToHash("0xabc123") + txHash := common.HexToHash("0xbeef") + + t.Run("errors when l1RpcUrl is unset", func(t *testing.T) { + t.Parallel() + result := &StepWaitResult{} + err := confirmVerifyBatchesOnL1(context.Background(), &Config{}, &StepSubmitResult{}, exitRoot, result) + require.Error(t, err) + require.Contains(t, err.Error(), "l1RpcUrl") + }) + + t.Run("errors when no rollup manager can be resolved", func(t *testing.T) { + t.Parallel() + result := &StepWaitResult{} + cfg := &Config{L1RPCURL: "http://unused"} // no rollupManagerAddress, no sovereignRollupAddr + err := confirmVerifyBatchesOnL1(context.Background(), cfg, &StepSubmitResult{}, exitRoot, result) + require.Error(t, err) + require.Nil(t, result.VerifyBatchesTxHash) + }) + + t.Run("resolves manager from sovereign, finds the event and the GER updates", func(t *testing.T) { + t.Parallel() + ger := common.HexToAddress("0xDDDdDddddddddDddDDddDDDDdDdDDdDDdDDDDddddD") + mainnetExitRoot := common.HexToHash("0x1111") + rollupExitRoot := common.HexToHash("0x2222") + currentL1InfoRoot := common.HexToHash("0x3333") + gerTxHash := common.HexToHash("0xfeed") + + srv := newRPCStub(t, func(method string, params []any) (json.RawMessage, *jsonRPCError) { + switch method { + case rpcMethodEthCall: + return hexResult(common.LeftPadBytes(rollupManager.Bytes(), 32)), nil + case rpcMethodEthGetBlockByNumber: + return json.RawMessage(`{"number":"0x64"}`), nil // finalized = 100 + case rpcMethodEthGetLogs: + switch getLogsTopic0(t, params) { + case verifyBatchesTrustedAggregatorTopic.Hex(): + return logsResult(t, 50, txHash, verifyBatchesData(1, common.Hash{}, exitRoot)), nil + case updateL1InfoTreeTopic.Hex(): + return topicLogsResult(t, gerTxHash, + []common.Hash{updateL1InfoTreeTopic, mainnetExitRoot, rollupExitRoot}, nil), nil + case updateL1InfoTreeV2TopicWait.Hex(): + return topicLogsResult(t, gerTxHash, + []common.Hash{updateL1InfoTreeV2TopicWait, common.BigToHash(big.NewInt(9))}, + v2Data(currentL1InfoRoot, common.Hash{}, 1700)), nil + } + } + t.Fatalf("unexpected call %s %v", method, params) + return nil, nil + }) + cfg := &Config{ + L1RPCURL: srv.URL, + SovereignRollupAddr: sovereign, + L1GlobalExitRootAddress: ger, + L2NetworkID: 5, + Options: Options{BlockRange: 50}, + } + submit := &StepSubmitResult{L1LatestBlockBeforeSubmittingCertificate: 10} + result := &StepWaitResult{} + err := confirmVerifyBatchesOnL1(context.Background(), cfg, submit, exitRoot, result) + require.NoError(t, err) + require.Equal(t, uint64(50), result.VerifyBatchesL1Block) + require.NotNil(t, result.VerifyBatchesTxHash) + require.Equal(t, txHash, *result.VerifyBatchesTxHash) + + require.NotNil(t, result.UpdateL1InfoTree) + require.Equal(t, mainnetExitRoot, result.UpdateL1InfoTree.MainnetExitRoot) + require.Equal(t, rollupExitRoot, result.UpdateL1InfoTree.RollupExitRoot) + + require.NotNil(t, result.UpdateL1InfoTreeV2) + require.Equal(t, currentL1InfoRoot, result.UpdateL1InfoTreeV2.CurrentL1InfoRoot) + require.Equal(t, uint32(9), result.UpdateL1InfoTreeV2.LeafCount) + require.Equal(t, uint64(1700), result.UpdateL1InfoTreeV2.MinTimestamp) + }) + + t.Run("errors when l1GlobalExitRootAddress is unset", func(t *testing.T) { + t.Parallel() + srv := newRPCStub(t, func(method string, params []any) (json.RawMessage, *jsonRPCError) { + switch method { + case rpcMethodEthCall: + return hexResult(common.LeftPadBytes(rollupManager.Bytes(), 32)), nil + case rpcMethodEthGetBlockByNumber: + return json.RawMessage(`{"number":"0x64"}`), nil + case rpcMethodEthGetLogs: + return logsResult(t, 50, txHash, verifyBatchesData(1, common.Hash{}, exitRoot)), nil + } + return nil, nil + }) + cfg := &Config{ + L1RPCURL: srv.URL, + SovereignRollupAddr: sovereign, + L2NetworkID: 5, + Options: Options{BlockRange: 50}, + } + submit := &StepSubmitResult{L1LatestBlockBeforeSubmittingCertificate: 10} + err := confirmVerifyBatchesOnL1(context.Background(), cfg, submit, exitRoot, &StepWaitResult{}) + require.Error(t, err) + require.Contains(t, err.Error(), "l1GlobalExitRootAddress") + }) +} + +func TestFetchGERUpdatesInBlock(t *testing.T) { + t.Parallel() + ger := common.HexToAddress("0xDDDdDddddddddDddDDddDDDDdDdDDdDDdDDDDddddD") + gerTxHash := common.HexToHash("0xfeed") + + t.Run("takes the last event of each type", func(t *testing.T) { + t.Parallel() + srv := newRPCStub(t, func(method string, params []any) (json.RawMessage, *jsonRPCError) { + require.Equal(t, rpcMethodEthGetLogs, method) + switch getLogsTopic0(t, params) { + case updateL1InfoTreeTopic.Hex(): + // Two events; the last one must win. + out, err := json.Marshal([]map[string]any{ + {"transactionHash": gerTxHash.Hex(), "topics": []string{ + updateL1InfoTreeTopic.Hex(), common.HexToHash("0xaa").Hex(), common.HexToHash("0xbb").Hex()}, "data": "0x"}, + {"transactionHash": gerTxHash.Hex(), "topics": []string{ + updateL1InfoTreeTopic.Hex(), common.HexToHash("0xcc").Hex(), common.HexToHash("0xdd").Hex()}, "data": "0x"}, + }) + require.NoError(t, err) + return out, nil + case updateL1InfoTreeV2TopicWait.Hex(): + return topicLogsResult(t, gerTxHash, + []common.Hash{updateL1InfoTreeV2TopicWait, common.BigToHash(big.NewInt(42))}, + v2Data(common.HexToHash("0xee"), common.HexToHash("0xff"), 12345)), nil + } + t.Fatalf("unexpected topic") + return nil, nil + }) + cfg := &Config{L1RPCURL: srv.URL, L1GlobalExitRootAddress: ger} + result := &StepWaitResult{} + require.NoError(t, fetchGERUpdatesInBlock(context.Background(), cfg, 50, result)) + + require.Equal(t, common.HexToHash("0xcc"), result.UpdateL1InfoTree.MainnetExitRoot) + require.Equal(t, common.HexToHash("0xdd"), result.UpdateL1InfoTree.RollupExitRoot) + require.Equal(t, uint32(42), result.UpdateL1InfoTreeV2.LeafCount) + require.Equal(t, common.HexToHash("0xee"), result.UpdateL1InfoTreeV2.CurrentL1InfoRoot) + require.Equal(t, common.HexToHash("0xff"), result.UpdateL1InfoTreeV2.Blockhash) + require.Equal(t, uint64(12345), result.UpdateL1InfoTreeV2.MinTimestamp) + }) + + t.Run("missing UpdateL1InfoTree is an error", func(t *testing.T) { + t.Parallel() + srv := newRPCStub(t, func(method string, params []any) (json.RawMessage, *jsonRPCError) { + return json.RawMessage(`[]`), nil // no logs of any kind + }) + cfg := &Config{L1RPCURL: srv.URL, L1GlobalExitRootAddress: ger} + err := fetchGERUpdatesInBlock(context.Background(), cfg, 50, &StepWaitResult{}) + require.Error(t, err) + require.Contains(t, err.Error(), "UpdateL1InfoTree") + }) +} diff --git a/tools/exit_certificate/types.go b/tools/exit_certificate/types.go new file mode 100644 index 000000000..56fcd2176 --- /dev/null +++ b/tools/exit_certificate/types.go @@ -0,0 +1,337 @@ +package exit_certificate + +import ( + "encoding/json" + "math/big" + + agglayertypes "github.com/agglayer/aggkit/agglayer/types" + "github.com/ethereum/go-ethereum/common" +) + +// StepWaitResult holds the outcome of the WAIT step. +type StepWaitResult struct { + CertificateHash common.Hash `json:"certificateHash"` + FinalStatus agglayertypes.CertificateStatus `json:"finalStatus"` + SettlementTxHash *common.Hash `json:"settlementTxHash,omitempty"` + ElapsedSeconds float64 `json:"elapsedSeconds"` + // VerifyBatchesL1Block and VerifyBatchesTxHash record where on L1 the RollupManager emitted + // the VerifyBatchesTrustedAggregator event matching this certificate's rollupID and exit root + // (the L1 block where the agglayer settled the certificate). Set only when rollupManagerAddress + // is configured and the event was found. + VerifyBatchesL1Block uint64 `json:"verifyBatchesL1Block,omitempty"` + VerifyBatchesTxHash *common.Hash `json:"verifyBatchesTxHash,omitempty"` + // UpdateL1InfoTree and UpdateL1InfoTreeV2 are the last respective events emitted by the L1 + // GlobalExitRoot contract in VerifyBatchesL1Block (the L1 info tree update that accompanies the + // certificate's settlement on L1). + UpdateL1InfoTree *L1InfoTreeUpdate `json:"updateL1InfoTree,omitempty"` + UpdateL1InfoTreeV2 *L1InfoTreeV2Update `json:"updateL1InfoTreeV2,omitempty"` +} + +// L1InfoTreeUpdate captures an UpdateL1InfoTree(bytes32 indexed mainnetExitRoot, +// bytes32 indexed rollupExitRoot) event from the L1 GlobalExitRoot contract. +type L1InfoTreeUpdate struct { + MainnetExitRoot common.Hash `json:"mainnetExitRoot"` + RollupExitRoot common.Hash `json:"rollupExitRoot"` + TxHash common.Hash `json:"txHash"` +} + +// L1InfoTreeV2Update captures an UpdateL1InfoTreeV2(bytes32 currentL1InfoRoot, +// uint32 indexed leafCount, uint256 blockhash, uint64 minTimestamp) event from the L1 +// GlobalExitRoot contract. +type L1InfoTreeV2Update struct { + CurrentL1InfoRoot common.Hash `json:"currentL1InfoRoot"` + LeafCount uint32 `json:"leafCount"` + Blockhash common.Hash `json:"blockhash"` + MinTimestamp uint64 `json:"minTimestamp"` + TxHash common.Hash `json:"txHash"` +} + +// WrappedToken describes a wrapped token deployed on L2 by the bridge contract. +type WrappedToken struct { + WrappedTokenAddress common.Address `json:"wrappedTokenAddress"` + OriginNetwork uint32 `json:"originNetwork"` + OriginTokenAddress common.Address `json:"originTokenAddress"` +} + +// LegacyToken records a wrapped token address that was replaced by a SetSovereignTokenAddress +// override, along with its totalSupply at the target block. +type LegacyToken struct { + Address common.Address `json:"address"` + Balance string `json:"balance"` +} + +// Step0Result holds the output of Step 0 (LBT generation). +type Step0Result struct { + TargetBlock uint64 `json:"targetBlock"` + Entries []LBTEntry `json:"entries"` +} + +// LBTEntry is a single entry from the Local Balance Tree file exported by the getLBT tool. +type LBTEntry struct { + WrappedTokenAddress common.Address `json:"wrappedTokenAddress"` + OriginNetwork uint32 `json:"originNetwork"` + OriginTokenAddress common.Address `json:"originTokenAddress"` + Balance string `json:"balance"` + // LegacyTokens holds previous wrapped addresses (replaced via SetSovereignTokenAddress) + // and their totalSupply at the target block. Populated only when an override was applied. + LegacyTokens []LegacyToken `json:"legacyTokens,omitempty"` +} + +// EOATokenBalance records a single token balance for an EOA. +type EOATokenBalance struct { + WrappedTokenAddress common.Address `json:"wrappedTokenAddress"` + OriginNetwork uint32 `json:"originNetwork"` + OriginTokenAddress common.Address `json:"originTokenAddress"` + Balance string `json:"balance"` +} + +// EOABalance holds all non-zero balances for a single EOA address. +type EOABalance struct { + Address common.Address `json:"address"` + ETHBalance string `json:"ethBalance"` + Tokens []EOATokenBalance `json:"tokens"` +} + +// AccumulatedBalance holds the total balance across all EOAs for a single token. +type AccumulatedBalance struct { + WrappedTokenAddress common.Address `json:"wrappedTokenAddress"` + OriginNetwork uint32 `json:"originNetwork"` + OriginTokenAddress common.Address `json:"originTokenAddress"` + TotalBalance string `json:"totalBalance"` +} + +// SCLockedValue holds the computed smart-contract-locked value for a single token. +type SCLockedValue struct { + WrappedTokenAddress common.Address `json:"wrappedTokenAddress"` + OriginNetwork uint32 `json:"originNetwork"` + OriginTokenAddress common.Address `json:"originTokenAddress"` + LBTBalance string `json:"lbtBalance"` + EOAAccumulated string `json:"eoaAccumulated"` + // ERC20HoldersCovered is the portion of SC-locked value distributed as individual + // bridge exits to holders of ERC-20 vault contracts (from Step B3 breakdowns). + // Empty when no breakdown applies to this token. + ERC20HoldersCovered string `json:"erc20HoldersCovered,omitempty"` + // TotalSCLockedBalance is the gross value locked in smart contracts: LBT - EOA. + // It includes both the portion covered by ERC-20 holder bridges and the remainder. + TotalSCLockedBalance string `json:"totalSCLockedBalance"` + // PendingSCLockedBalance is the net SC-locked value that requires a bridge exit to + // exitAddress: TotalSCLockedBalance − ERC20HoldersCovered. + PendingSCLockedBalance string `json:"pendingSCLockedBalance"` +} + +// FailedTrace pairs a transaction hash with the RPC error that caused its trace to fail. +type FailedTrace struct { + Hash common.Hash `json:"hash"` + Error string `json:"error"` +} + +// StepAResult holds the combined output of Step A (A1 + A2). +type StepAResult struct { + Addresses []common.Address `json:"addresses"` + FailedTraces []FailedTrace `json:"failedTraces"` + WrappedTokens []WrappedToken `json:"-"` +} + +// StepA2Result holds addresses recovered from tx receipts of failed traces (Step A2). +type StepA2Result struct { + Addresses []common.Address `json:"addresses"` +} + +// StepB1Result holds the output produced exclusively by Step B1 +// (address classification and balance fetching). It does not include +// the ERC-20 detection data added by Step B2. +type StepB1Result struct { + EOABalances []EOABalance `json:"eoaBalances"` + Accumulated []AccumulatedBalance `json:"accumulated"` + ContractAddresses []common.Address `json:"contractAddresses"` +} + +// StepBResult holds the combined output of Step B (B1 + B2 + B3). +type StepBResult struct { + EOABalances []EOABalance `json:"eoaBalances"` + Accumulated []AccumulatedBalance `json:"accumulated"` + ContractAddresses []common.Address `json:"contractAddresses"` + DetectedERC20s []DetectedERC20 `json:"detectedErc20s,omitempty"` + DiscardedERC20s []DiscardedERC20 `json:"discardedErc20s,omitempty"` + ERC20HolderBreakdowns []ERC20HolderBreakdown `json:"erc20HolderBreakdowns,omitempty"` +} + +// ERC20HolderBreakdown holds the full holder decomposition for a single ERC-20 contract +// produced by Step B3. +type ERC20HolderBreakdown struct { + Address common.Address `json:"address"` + Holders []ERC20Holder `json:"holders"` + // Detected is the collateral info from Step B2: which tracked wrapped tokens this + // contract holds, plus its name/symbol/totalSupply. Nil when the contract was not + // present in the B2 detected list (e.g. it holds no tracked wrapped tokens). + Detected *DetectedERC20 `json:"detected,omitempty"` +} + +// StepB3Result holds the output of Step B3 (extra ERC-20 holder decomposition). +type StepB3Result struct { + Breakdowns []ERC20HolderBreakdown `json:"breakdowns"` +} + +// StepB2Result holds the output of Step B2. +type StepB2Result struct { + // DetectedERC20s are contracts that hold at least one tracked wrapped token. + DetectedERC20s []DetectedERC20 `json:"detectedErc20s"` + // DiscardedERC20s are contracts that responded to totalSupply() but hold none + // of the tracked wrapped tokens and are therefore irrelevant to the certificate. + DiscardedERC20s []DiscardedERC20 `json:"discardedErc20s,omitempty"` +} + +// DetectedERC20 holds an ERC-20 contract that holds at least one tracked wrapped token. +type DetectedERC20 struct { + Address common.Address `json:"address"` + Name string `json:"name,omitempty"` + Symbol string `json:"symbol,omitempty"` + TotalSupply string `json:"totalSupply"` + WrappedTokenBalances []WrappedTokenBalance `json:"wrappedTokenBalances"` +} + +// DiscardedERC20 is an ERC-20 contract that holds none of the tracked wrapped tokens. +type DiscardedERC20 struct { + Address common.Address `json:"address"` + Name string `json:"name,omitempty"` + Symbol string `json:"symbol,omitempty"` + TotalSupply string `json:"totalSupply"` +} + +// WrappedTokenBalance is the balance of a tracked wrapped token held by an ERC-20 contract. +type WrappedTokenBalance struct { + Token WrappedToken `json:"token"` + Balance string `json:"balance"` +} + +// ERC20Holder is an (address, balance) pair produced by Step B2. +type ERC20Holder struct { + Address common.Address `json:"address"` + Balance string `json:"balance"` +} + +// HolderBridge is an individual bridge exit for a holder of an ERC-20 vault/staking +// contract, representing their proportional share of the tracked wrapped tokens locked +// inside that contract. Produced by Step C from the Step B3 breakdown data. +type HolderBridge struct { + VaultAddress common.Address `json:"vaultAddress"` + WrappedTokenAddress common.Address `json:"wrappedTokenAddress"` + OriginNetwork uint32 `json:"originNetwork"` + OriginTokenAddress common.Address `json:"originTokenAddress"` + HolderAddress common.Address `json:"holderAddress"` + Amount string `json:"amount"` +} + +// StepCResult holds the output of Step C. +type StepCResult struct { + SCLockedValues []SCLockedValue `json:"scLockedValues"` + // HolderBridges are individual bridge exits for holders of ERC-20 vault contracts + // whose breakdowns were provided by Step B3. These replace what would otherwise be a + // single SC-locked exit to exitAddress for the portion of value they cover. + HolderBridges []HolderBridge `json:"holderBridges,omitempty"` +} + +// StepDResult holds the output of Step D. +type StepDResult struct { + Certificate *agglayertypes.Certificate `json:"certificate"` +} + +// L1Deposit represents an L1 bridge deposit targeting the L2 chain. +type L1Deposit struct { + LeafType uint8 `json:"leafType"` + OriginNetwork uint32 `json:"originNetwork"` + OriginAddress common.Address `json:"originAddress"` + DestinationNetwork uint32 `json:"destinationNetwork"` + DestinationAddress common.Address `json:"destinationAddress"` + Amount *big.Int `json:"amount"` + Metadata []byte `json:"metadata"` + DepositCount uint32 `json:"depositCount"` + BlockNumber uint64 `json:"blockNumber"` + TxHash common.Hash `json:"txHash"` +} + +// StepEResult holds the output of Step E. +type StepEResult struct { + // UnclaimedBridges are unclaimed L1→L2 deposits with leaf_type=asset that were added + // to the certificate as bridge exits and imported bridge exits. + UnclaimedBridges []L1Deposit `json:"unclaimedBridges"` + // UnclaimedMessages are unclaimed L1→L2 deposits with leaf_type=message. These are + // logged as warnings but NOT added to the certificate (messages are not transferable assets). + UnclaimedMessages []L1Deposit `json:"unclaimedMessages,omitempty"` + FinalCertificate *agglayertypes.Certificate `json:"finalCertificate"` +} + +// CertificateEntry is one bridge exit entry for a given token, used in mismatch reports. +type CertificateEntry struct { + DestinationNetwork uint32 `json:"destinationNetwork"` + DestinationAddress string `json:"destinationAddress"` + Amount string `json:"amount"` +} + +// TokenBalanceCheck holds the three-way comparison between Step 0 (LBT), the certificate bridge exits, +// and the agglayer state for one token. LBTAmount is empty when LBT data was not available. +type TokenBalanceCheck struct { + OriginNetwork uint32 `json:"originNetwork"` + OriginTokenAddress string `json:"originTokenAddress"` + LBTAmount string `json:"lbtAmount,omitempty"` + CertificateAmount string `json:"certificateAmount"` + AgglayerAmount string `json:"agglayerAmount"` + Match bool `json:"match"` + CertificateEntries []CertificateEntry `json:"certificateEntries,omitempty"` + // RemainingBalance is the cap budget for this token: min(LBT, agglayer). + // Not persisted to JSON; used internally by capCertificateExits. + RemainingBalance *big.Int `json:"-"` +} + +// StepFResult holds the output of Step F (agglayer token balance check). +type StepFResult struct { + AllMatch bool `json:"allMatch,omitempty"` + TokenBalances json.RawMessage `json:"tokenBalances,omitempty"` + Checks []TokenBalanceCheck `json:"checks,omitempty"` + // CappedCertificate is set when mismatches were found and ignoreBalanceMismatch=true. + // Bridge exits are proportionally scaled down to min(agglayer, lbt) per token. + CappedCertificate *agglayertypes.Certificate `json:"cappedCertificate,omitempty"` +} + +// StepCheckResult holds the output of Step CHECK (prerequisite verification). +type StepCheckResult struct { + AnvilInstalled bool `json:"anvilInstalled"` + BridgeNetworkID uint32 `json:"bridgeNetworkID"` + NetworkType string `json:"networkType"` + Threshold uint64 `json:"threshold"` + SignerCount int `json:"signerCount"` + Signers []string `json:"signers,omitempty"` + GasTokenAddress string `json:"gasTokenAddress,omitempty"` + GasTokenNetwork uint32 `json:"gasTokenNetwork,omitempty"` + WETHToken string `json:"wethToken,omitempty"` +} + +// StepG1Result holds the output of Step G1: the L2 block at which Step G2 spins up its Anvil +// shadow-fork. Step G1 lite-syncs the L2 bridge history from genesis up to that block into the lite +// DB Step G2 reuses. +type StepG1Result struct { + // ShadowForkBlock is the L2 block Step G2 forks at — the resolved targetBlock up to which Step G1 + // lite-synced the bridge history. + ShadowForkBlock uint64 `json:"shadowForkBlock"` +} + +// StepGResult holds the output of Step G (NewLocalExitRoot calculation). +type StepGResult struct { + // InitialLocalExitRoot is the LER read from the bridge contract at targetBlock, + // before any bridge exits from the certificate are replayed. + InitialLocalExitRoot common.Hash `json:"initialLocalExitRoot"` + NewLocalExitRoot common.Hash `json:"newLocalExitRoot"` + BridgeExitCount uint64 `json:"bridgeExitCount"` + // BridgeExitMetadata holds the Metadata field from the BridgeEvent emitted for each + // replayed bridge exit, in the same order as Certificate.BridgeExits. Step I applies + // these values to each BridgeExit.Metadata before finalising the certificate. + BridgeExitMetadata [][]byte `json:"bridgeExitMetadata,omitempty"` +} + +// StepHResult holds the output of Step H (PreviousLocalExitRoot and next height from agglayer). +type StepHResult struct { + PreviousLocalExitRoot common.Hash `json:"previousLocalExitRoot"` + // Height is the certificate height to use for the exit certificate (settled_height + 1, + // or 0 if no certificate has been settled yet). + Height uint64 `json:"height"` +} diff --git a/tools/exit_certificate/worker.go b/tools/exit_certificate/worker.go new file mode 100644 index 000000000..903e7726e --- /dev/null +++ b/tools/exit_certificate/worker.go @@ -0,0 +1,141 @@ +package exit_certificate + +import ( + "context" + "errors" + "sync" + + "github.com/agglayer/aggkit/log" +) + +const ( + workerPoolChannelCap = 10000 + resultChannelMultiplier = 2 + logGranularity = 20 + percentMultiplier = 100 +) + +type workerResult[R any] struct { + val R + err error +} + +// runWorkerPool fans out work across `concurrency` goroutines. +// It feeds `jobs` into a channel, workers call `fn` for each job, and results +// are collected via `collect`. Progress is logged at ~5% intervals. +// When ctx is cancelled, the feeder and workers stop immediately and +// collectResults returns as soon as the last in-flight result is received. +// +// This is the single concurrency primitive used by all steps, replacing +// duplicated goroutine+channel boilerplate. +func runWorkerPool[J any, R any]( + ctx context.Context, + jobs []J, + concurrency int, + fn func(J) (R, error), + collect func(R), + label string, +) error { + if len(jobs) == 0 { + return nil + } + + resultCh := startWorkers(ctx, jobs, concurrency, fn) + return collectResults(ctx, resultCh, len(jobs), collect, label) +} + +func startWorkers[J any, R any]( + ctx context.Context, + jobs []J, + concurrency int, + fn func(J) (R, error), +) <-chan workerResult[R] { + jobCh := make(chan J, min(len(jobs), workerPoolChannelCap)) + go func() { + defer close(jobCh) + for _, j := range jobs { + select { + case jobCh <- j: + case <-ctx.Done(): + return + } + } + }() + + resultCh := make(chan workerResult[R], concurrency*resultChannelMultiplier) + var wg sync.WaitGroup + for w := 0; w < concurrency; w++ { + wg.Add(1) + go func() { + defer wg.Done() + for { + select { + case j, ok := <-jobCh: + if !ok { + return + } + val, err := fn(j) + resultCh <- workerResult[R]{val: val, err: err} + case <-ctx.Done(): + return + } + } + }() + } + go func() { + wg.Wait() + close(resultCh) + }() + + return resultCh +} + +func collectResults[R any]( + ctx context.Context, + resultCh <-chan workerResult[R], + total int, + collect func(R), + label string, +) error { + logInterval := total / logGranularity + if logInterval < 1 { + logInterval = 1 + } + + processed := 0 + var firstErr error + for { + select { + case <-ctx.Done(): + // Drain resultCh synchronously so all in-flight workers finish before we return. + // A background drain would let workers mutate captured state after the caller returns. + for range resultCh { + } + if firstErr != nil { + return firstErr + } + return ctx.Err() + case r, ok := <-resultCh: + if !ok { + return firstErr + } + processed++ + if r.err != nil { + if firstErr == nil { + firstErr = r.err + } + // Skip context.Canceled: it's the expected fallout of cancelling the pool after a + // real failure (the root-cause error is kept in firstErr), not noise worth logging. + if !errors.Is(r.err, context.Canceled) { + log.Warnf("%s job failed: %v req: %+v", label, r.err, r.val) + } + } else { + collect(r.val) + if label != "" && (processed%logInterval == 0 || processed == total) { + pct := float64(processed) / float64(total) * percentMultiplier + log.Infof(" %s: %d/%d [%.0f%%]", label, processed, total, pct) + } + } + } + } +} diff --git a/tools/exit_certificate/worker_test.go b/tools/exit_certificate/worker_test.go new file mode 100644 index 000000000..1a431ac4c --- /dev/null +++ b/tools/exit_certificate/worker_test.go @@ -0,0 +1,75 @@ +package exit_certificate + +import ( + "context" + "errors" + "sort" + "sync" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestRunWorkerPoolEmpty(t *testing.T) { + t.Parallel() + called := false + err := runWorkerPool(context.Background(), []int{}, 4, + func(j int) (int, error) { called = true; return j, nil }, + func(int) {}, "") + require.NoError(t, err) + require.False(t, called, "fn must not be called for an empty job list") +} + +func TestRunWorkerPoolSuccess(t *testing.T) { + t.Parallel() + jobs := make([]int, 100) + for i := range jobs { + jobs[i] = i + } + + var mu sync.Mutex + var got []int + err := runWorkerPool(context.Background(), jobs, 8, + func(j int) (int, error) { return j * 2, nil }, + func(r int) { mu.Lock(); got = append(got, r); mu.Unlock() }, + "double") + require.NoError(t, err) + require.Len(t, got, len(jobs)) + + sort.Ints(got) + for i := range jobs { + require.Equal(t, jobs[i]*2, got[i]) + } +} + +func TestRunWorkerPoolPropagatesError(t *testing.T) { + t.Parallel() + wantErr := errors.New("boom") + jobs := []int{1, 2, 3, 4, 5} + + err := runWorkerPool(context.Background(), jobs, 2, + func(j int) (int, error) { + if j == 3 { + return 0, wantErr + } + return j, nil + }, + func(int) {}, "maybe-fail") + require.Error(t, err) + require.ErrorIs(t, err, wantErr) +} + +func TestRunWorkerPoolContextCanceled(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithCancel(context.Background()) + cancel() // cancel up front + + jobs := make([]int, 1000) + err := runWorkerPool(ctx, jobs, 4, + func(j int) (int, error) { return j, nil }, + func(int) {}, "cancelled") + // Either a clean drain returning ctx.Err, or nil if everything happened to complete first. + if err != nil { + require.ErrorIs(t, err, context.Canceled) + } +} diff --git a/tools/exit_certificate_claimer/README.md b/tools/exit_certificate_claimer/README.md new file mode 100644 index 000000000..7cc4117a2 --- /dev/null +++ b/tools/exit_certificate_claimer/README.md @@ -0,0 +1,122 @@ +# exit_certificate_claimer + +Backend (and, later, frontend) companion to the [`exit_certificate`](../exit_certificate) tool. +Given a destination address it returns the bridge exits available for that address and the full set +of parameters needed to call +[`AgglayerBridge.claimAsset`](https://github.com/agglayer/agglayer-contracts/blob/110bda5a03e70ee7331bc06407a8e79226d3e520/contracts/AgglayerBridge.sol#L537) +on L1. + +``` +tools/exit_certificate_claimer/ +├── service/ Go HTTP service (this document) +└── frontend/ (not implemented yet) +``` + +## What it does + +`claimAsset` requires a local-exit-tree proof, a rollup-exit-tree proof, the L1 exit roots, the +global index, and the bridge-leaf fields. This service assembles all of them from three sources: + +| `claimAsset` argument | Source | +| ---------------------------- | ------ | +| `smtProofLocalExitRoot` | `step-g-l2bridgesyncerlite.sqlite` (the L2 local exit tree) — proof of the leaf at its deposit count against `new_local_exit_root` | +| `smtProofRollupExitRoot` | L1 Info Tree DB — `GetRollupExitTreeMerkleProof(networkID, rollupExitRoot)` | +| `globalIndex` | `GenerateGlobalIndexForNetworkID(networkID, depositCount)` | +| `mainnetExitRoot` / `rollupExitRoot` | the selected L1 Info Tree leaf | +| `originNetwork`, `originTokenAddress`, `destinationNetwork`, `destinationAddress`, `amount`, `metadata` | `exit-certificate-signed.json` (`bridge_exits[]`) | + +The bridge-exit list is taken from the signed certificate; each exit is matched to its deposit count +(the exit-tree leaf index) by recomputing its canonical leaf hash and looking it up in the local +exit tree database. + +> **Settlement requirement.** The certificate's `new_local_exit_root` must already be settled on L1 +> — i.e. present in the rollup exit tree of some L1 Info Tree leaf. `/claim-params` verifies this +> against the selected leaf (latest by default) and returns HTTP `409` if it is not yet settled. + +## Configuration + +JSON or TOML, selected by file extension. See [config.toml.example](service/config.toml.example). +Relative paths resolve against the directory containing the config file. + +| Field | Required | Description | +| ----- | -------- | ----------- | +| `address` | no (default `0.0.0.0`) | HTTP bind host/IP (without port) | +| `port` | no (default `8080`) | HTTP bind port | +| `signedCertificatePath` | yes | path to `exit-certificate-signed.json` | +| `localExitTreeDBPath` | yes | path to `step-g-l2bridgesyncerlite.sqlite` | +| `l1InfoTreeDBPath` | yes | path to the l1infotreesync SQLite DB | +| `stepWaitResultPath` | yes | path to `step-wait-result.json` (the WAIT step's L1 settlement record) | +| `networkId` | no | source network; defaults to the certificate's `network_id` | +| `l1Sync.enabled` | no | when `false` the L1 Info Tree DB is opened read-only; when `true` it is kept in sync from L1 | +| `l1Sync.rpcUrl`, `l1Sync.globalExitRootAddr`, `l1Sync.rollupManagerAddr`, … | when `l1Sync.enabled` | L1 sync parameters | + +> **Settlement GER check on startup.** From the WAIT step's `updateL1InfoTree` event the claimer +> derives the certificate's settlement Global Exit Root (`keccak256(mainnetExitRoot, rollupExitRoot)`) +> and checks whether it is already indexed in `l1InfoTreeDBPath`. If it is, the DB is caught up to +> settlement and no L1 sync is started (regardless of `l1Sync.enabled`). If it is **not** indexed it +> must be synced from L1: with `l1Sync.enabled=true` the claimer syncs from L1 **only until the +> settlement GER is indexed**, then stops the sync and serves from that state; with sync disabled it +> **fails fast** with an error pointing at `l1Sync`. The HTTP server is started **only after** this +> sync completes — it does not bind until the L1 Info Tree is caught up to the settlement GER, so any +> reachable endpoint is already ready to serve claim requests (which is why `/health` always returns +> `ok`). + +### Deriving the config from the exit_certificate tool + +Instead of maintaining a separate claimer config you can derive it directly from an +[`exit_certificate`](../exit_certificate) config file with `--exit-certificate-config` +(mutually exclusive with `--config`). The claimer reuses the exit_certificate's output directory, +L1 RPC, contracts and tuning, and enables L1 sync so it keeps its own L1 Info Tree DB up to date. + +| Derived claimer field | Source in the exit_certificate config | +| --------------------- | ------------------------------------- | +| `signedCertificatePath` | `options.outputDir` + `/exit-certificate-signed.json` | +| `localExitTreeDBPath` | `options.outputDir` + `/step-g-l2bridgesyncerlite.sqlite` | +| `l1InfoTreeDBPath` | `options.outputDir` + `/L1InfoTreeSync.sqlite` | +| `stepWaitResultPath` | `options.outputDir` + `/step-wait-result.json` | +| `networkId` | `l2NetworkId` | +| `l1Sync.enabled` | always `true` | +| `l1Sync.rpcUrl` | `l1RpcUrl` | +| `l1Sync.globalExitRootAddr` | `l1GlobalExitRootAddress` | +| `l1Sync.rollupManagerAddr` | `RollupManager()` read on-chain from the `aggchainbase` contract at `sovereignRollupAddr` | +| `l1Sync.initialBlock` | `options.l1StartBlock` | +| `l1Sync.syncBlockChunkSize` | `options.blockRange` | +| `l1Sync.blockFinality` | fixed `FinalizedBlock` | +| `address`, timeouts | claimer defaults | + +The L1 sync uses the multidownloader-based l1infotreesync implementation, which keeps its own +storage and reorg processor (`l1infotree_multidownloader.sqlite`) next to the L1 Info Tree DB. + +> Because `rollupManagerAddr` is not part of the exit_certificate config, deriving always makes an +> L1 RPC call to resolve it; `l1RpcUrl` and `sovereignRollupAddr` must be set and reachable. + +## HTTP API + +The HTTP API (endpoints, query parameters, response schemas, and error model) is fully specified in +[SPEC.md](SPEC.md#http-api). Base path: `/claimer/v1`. + +## Build & run + +```bash +make -C ../../.. $(go env GOPATH 2>/dev/null)/dev/null # (or use the repo Makefile) +# from the repo root: +make build-tools # builds all tools, including exit_certificate_claimer +# or directly: +CGO_ENABLED=1 go build -o exit-certificate-claimer ./tools/exit_certificate_claimer/service/cmd + +./exit-certificate-claimer --config tools/exit_certificate_claimer/service/config.toml + +# derive the config from an exit_certificate config instead: +./exit-certificate-claimer --exit-certificate-config tools/exit_certificate/parameters.toml + +# override the bind host/port from the command line (works in both modes): +./exit-certificate-claimer --config config.toml --address 127.0.0.1 --port 9090 +``` + +`CGO_ENABLED=1` is required (SQLite via `mattn/go-sqlite3`). + +## Tests + +```bash +go test ./tools/exit_certificate_claimer/... +``` diff --git a/tools/exit_certificate_claimer/SPEC.md b/tools/exit_certificate_claimer/SPEC.md new file mode 100644 index 000000000..2836e845c --- /dev/null +++ b/tools/exit_certificate_claimer/SPEC.md @@ -0,0 +1,254 @@ +# Exit Certificate Claimer — Specification + +## Purpose & Audience + +This document specifies the end-to-end flow and the public contract of the +**exit certificate claimer**: the backend service that, given a destination address, returns the +bridge exits available for that address and the full set of parameters needed to call +[`AgglayerBridge.claimAsset`](https://github.com/agglayer/agglayer-contracts/blob/110bda5a03e70ee7331bc06407a8e79226d3e520/contracts/AgglayerBridge.sol#L537) +on L1. + +Stakeholders: + +- **The `@apps-team`** — implementing the UI (frontend/backend). +- **The `@team-agglayer-aggkit`** — implementing the exit certificate claimer, which acts as the + kind of bridge service. + +## Scenario + +This tool exists to support **deprecating (shutting down) an L2 network** while ensuring its users +can still recover their funds. + +1. **Move the funds from L2 to L1.** Before the network is shut down, we generate one **final exit + certificate** for it and send it to *Agglayer*. This is done with the + [`exit_certificate`](../exit_certificate) tool, which both generates the certificate and submits + it to Agglayer. +2. **Wait for the certificate to be `Settled`.** Once the certificate reaches the `Settled` state on + L1, the L2 network being closed is no longer needed and can be **stopped** — from this point on, + all claim operations happen on L1. +3. **Expose the claim API.** With the network stopped, we launch the **exit certificate claimer**, + which exposes an API that lets users recover the funds they held on the deprecated network. + +The flow described in this document covers step 3: how a user goes from wanting to claim those funds +to having them in their wallet on L1. + +## Scope + +In scope: + +- The claim flow from a user wanting to recover funds that were on the zkEVM until those funds land + in their wallet on L1. +- The HTTP API the claimer service exposes and the data it returns. + +Out of scope (handled by other actors): + +- Generating and signing the exit certificate (done by the + [`exit_certificate`](../exit_certificate) tool). +- Building, signing, and submitting the `claimAsset` transaction (done by the UI + the user's + wallet). The claimer service is **read-only**: it never sends transactions. + +## Actors + +| Actor | Description | +| ----- | ----------- | +| **User** | The owner of the funds. Interacts with the UI and signs the L1 `claimAsset` transaction with their wallet. | +| **UI frontend** | Browser/app the user interacts with. Talks to the UI backend and to the user's wallet. | +| **UI backend** | The UI team's server. Orchestrates calls to the claimer service on behalf of the frontend. | +| **Claimer service** | This tool. Read-only HTTP service that lists bridge exits and assembles `claimAsset` parameters from the signed certificate and the exit-tree / L1 Info Tree databases. | +| **L1 RPC** | The L1 node endpoint. Hosts the `AgglayerBridge` contract where `claimAsset` is called, and is also the source the claimer uses to keep its L1 Info Tree DB in sync. | + +## End-to-End Flow + +From the user wanting to claim funds that were on the zkEVM until those funds are in their wallet +on L1. + +```mermaid +sequenceDiagram + actor User + participant FE as UI frontend + participant BE as UI backend + participant CL as Claimer service + participant L1 as L1 RPC
(AgglayerBridge) + + User->>FE: Open claim UI, provide destination address + FE->>BE: Request available bridge exits for address + BE->>CL: GET /bridges?dest_address=0x… + CL-->>BE: Bridge exits (deposit_count, leaf_hash, amount, token…) + BE-->>FE: Bridge exits + FE-->>User: Show claimable exits + + User->>FE: Select exits and start claim + FE->>BE: Request claim parameters for address + BE->>CL: GET /claim-params?dest_address=0x…[&l1_info_tree_index=N] + Note over CL: Build local-exit-tree proof + rollup-exit-tree proof,
resolve global index and L1 exit roots,
verify local exit root is settled (else 409). + CL-->>BE: claimAsset parameter set per exit + BE-->>FE: claimAsset parameters + + FE->>FE: Build claimAsset transaction + FE->>User: Request signature + User->>FE: Sign with wallet + FE->>L1: Submit claimAsset(...) transaction + L1->>L1: Verify proofs, mint/transfer funds + L1-->>FE: Transaction receipt + FE-->>User: Funds claimed — now in wallet +``` + +## HTTP API + +All endpoints are served under the base path **`/claimer/v1`** and respond with `application/json`. +The service is read-only: it never sends transactions. + +Conventions: + +- Addresses and hashes are `0x`-prefixed hex strings. Addresses are EIP-55 checksummed. +- Amounts and the global index are decimal strings (they can exceed 64-bit range). +- `metadata` is a `0x`-prefixed hex string (`"0x"` when empty). +- On error, the body is `{"error": ""}` with the corresponding HTTP status code. + +### `GET /health` + +Liveness/readiness probe. + +**Response — `200 OK`** + +```json +{ + "status": "ok", + "network_id": 1 +} +``` + +| Field | Type | Description | +| ----- | ---- | ----------- | +| `status` | string | Always `ok`. The HTTP server only starts after the L1 Info Tree has been synced up to the certificate's settlement GER, so a reachable endpoint is always ready to serve claim requests. | +| `network_id` | number | The source network ID the claimer is serving. | + +### `GET /bridges` + +Lists the certificate bridge exits destined to a given address, each enriched with its +`deposit_count` (the exit-tree leaf index) and `leaf_hash`. Use this to show the user what is +claimable before fetching the heavier proof material. + +**Query parameters:** + +| Name | Required | Type | Description | +| ---- | -------- | ---- | ----------- | +| `dest_address` | yes | hex address | The destination address to list bridge exits for. | + +**Response — `200 OK`** + +```json +{ + "network_id": 1, + "destination_address": "0xAbC0000000000000000000000000000000000001", + "bridges": [ + { + "leaf_type": 0, + "origin_network": 1, + "origin_token_address": "0x0000000000000000000000000000000000000000", + "destination_network": 0, + "destination_address": "0xAbC0000000000000000000000000000000000001", + "amount": "1000000000000000000", + "metadata": "0x", + "deposit_count": 42 + } + ] +} +``` + +| Field | Type | Description | +| ----- | ---- | ----------- | +| `network_id` | number | Source network ID. | +| `destination_address` | hex address | Echo of the requested address. | +| `bridges[]` | array | One entry per matching bridge exit. | +| `bridges[].leaf_type` | number | Always `0` (asset / Transfer). | +| `bridges[].origin_network` | number | Network the token originates from. | +| `bridges[].origin_token_address` | hex address | Origin token contract address. | +| `bridges[].destination_network` | number | Destination network ID. | +| `bridges[].destination_address` | hex address | Destination (recipient) address. | +| `bridges[].amount` | decimal string | Transferred amount. | +| `bridges[].metadata` | hex string | Bridge metadata (`"0x"` when empty). | +| `bridges[].deposit_count` | number | Exit-tree leaf index of this exit. | + +### `GET /claim-params` + +Returns the full `AgglayerBridge.claimAsset` argument set for the bridge exits destined to a given +address. This assembles the local-exit-tree proof, the rollup-exit-tree proof, the global index, and +the L1 exit roots, anchored to the latest L1 Info Tree leaf. + +**Query parameters:** + +| Name | Required | Type | Description | +| ---- | -------- | ---- | ----------- | +| `dest_address` | yes | hex address | The destination address to build claim parameters for. | +| `deposit_count` | no | number (uint32) | Select a single pending deposit by its exit-tree leaf index (an address may have more than one pending deposit). When omitted, all matching exits are returned. | + +**Response — `200 OK`** + +```json +{ + "network_id": 1, + "destination_address": "0xAbC0000000000000000000000000000000000001", + "claims": [ + { + "smt_proof_local_exit_root": ["0x…", "… 32 sibling hashes …"], + "smt_proof_rollup_exit_root": ["0x…", "… 32 sibling hashes …"], + "global_index": "18446744073709551658", + "mainnet_exit_root": "0xaaa…", + "rollup_exit_root": "0xbbb…", + "origin_network": 1, + "origin_token_address": "0x0000000000000000000000000000000000000000", + "destination_network": 0, + "destination_address": "0xAbC0000000000000000000000000000000000001", + "amount": "1000000000000000000", + "metadata": "0x", + "leaf_type": 0, + "deposit_count": 42, + "l1_info_tree_index": 7 + } + ] +} +``` + +Each entry in `claims[]` maps directly to the `claimAsset` call. The first 11 fields are the +contract arguments; the last three are context useful for callers and debugging. + +| Field | Type | `claimAsset` arg? | Description | +| ----- | ---- | ----------------- | ----------- | +| `smt_proof_local_exit_root` | string[32] | yes | Merkle proof of the leaf against `new_local_exit_root`. | +| `smt_proof_rollup_exit_root` | string[32] | yes | Merkle proof against the rollup exit root. | +| `global_index` | decimal string | yes | Global index for `(network_id, deposit_count)`. | +| `mainnet_exit_root` | hex hash | yes | Mainnet exit root of the latest L1 Info Tree leaf. | +| `rollup_exit_root` | hex hash | yes | Rollup exit root of the latest L1 Info Tree leaf. | +| `origin_network` | number | yes | Network the token originates from. | +| `origin_token_address` | hex address | yes | Origin token contract address. | +| `destination_network` | number | yes | Destination network ID. | +| `destination_address` | hex address | yes | Destination (recipient) address. | +| `amount` | decimal string | yes | Transferred amount. | +| `metadata` | hex string | yes | Bridge metadata (`"0x"` when empty). | +| `leaf_type` | number | no (context) | `0` = asset, `1` = message. | +| `deposit_count` | number | no (context) | Exit-tree leaf index. | +| `l1_info_tree_index` | number | no (context) | The latest L1 Info Tree leaf the proofs are anchored to. | + +### Error responses + +| Status | When | +| ------ | ---- | +| `400 Bad Request` | Missing or malformed `dest_address`, or malformed `deposit_count`. | +| `409 Conflict` | The certificate's local exit root is not yet settled in the latest L1 Info Tree leaf. | +| `500 Internal Server Error` | Unexpected failure assembling the response (DB read, proof generation, etc.). | + +Error body: + +```json +{ "error": "dest_address query parameter is required" } +``` + +## Open Points / TODO + +*To be defined:* + +- Error model and status codes beyond the current `400` / `409` / `500`. +- Transaction-status feedback loop (does the UI poll L1, or does the claimer help?). +- UI: how to know whether a bridge exit has already been claimed on L1. + diff --git a/tools/exit_certificate_claimer/scripts/README.md b/tools/exit_certificate_claimer/scripts/README.md new file mode 100644 index 000000000..34580b6da --- /dev/null +++ b/tools/exit_certificate_claimer/scripts/README.md @@ -0,0 +1,85 @@ +# exit_certificate_claimer scripts + +Bash helpers that talk to the running [`exit_certificate_claimer`](../service) HTTP service +(`/claimer/v1`). They require `curl` and `jq`; `claim-asset.sh` additionally needs +[`cast`](https://book.getfoundry.sh/cast/) (foundry) to submit the transaction. + +| Script | What it does | +| ------ | ------------ | +| [`list-bridges.sh`](list-bridges.sh) | Given a destination address, lists the bridge exits (deposits) associated with it via `GET /bridges`. | +| [`claim-asset.sh`](claim-asset.sh) | Fetches the `claimAsset` parameters for one deposit via `GET /claim-params` and submits `AgglayerBridge.claimAsset` on L1. | +| [`claim-all.sh`](claim-all.sh) | Claims every pending deposit for all addresses of an exit run (the EOAs in `step-b-eoa-balances.json` plus the config's `exitAddress`), delegating each claim to `claim-asset.sh`. | + +All scripts read the service base URL from `CLAIMER_URL` (default `http://localhost:8080`, +except `claim-all.sh` which defaults to `127.0.0.1:7080`). + +## List the deposits of an address + +```bash +./list-bridges.sh 0xAbC0000000000000000000000000000000000001 +# against a remote service: +CLAIMER_URL=http://10.0.0.5:9090 ./list-bridges.sh 0xAbC...001 +``` + +Each row shows the `deposit_count` you pass to `claim-asset.sh`. + +## Claim an asset + +```bash +# 1. Preview the parameters and the exact cast command (no transaction): +DRY_RUN=1 ./claim-asset.sh 0xAbC...001 42 + +# 2. Submit the claimAsset transaction: +L1_RPC_URL=http://localhost:8545 \ +BRIDGE_ADDRESS=0xYourAgglayerBridgeAddress \ +PRIVATE_KEY=0xyourkey \ + ./claim-asset.sh 0xAbC...001 42 +``` + +`` selects a single pending deposit (an address may have several). The script +prints the parameters and prompts for confirmation before sending; set `ASSUME_YES=1` to skip +the prompt. + +| Env var | Required | Description | +| ------- | -------- | ----------- | +| `CLAIMER_URL` | no (default `http://localhost:8080`) | Claimer service base URL. | +| `L1_RPC_URL` | to submit | L1 RPC endpoint hosting `AgglayerBridge`. | +| `BRIDGE_ADDRESS` | to submit | `AgglayerBridge` contract address on L1. | +| `PRIVATE_KEY` | to submit | Signing key for the `claimAsset` transaction. | +| `DRY_RUN` | no | `1` → only print params and the cast command. | +| `ASSUME_YES` | no | `1` → skip the confirmation prompt. | + +> If the claimer returns `409 Conflict`, the certificate's local exit root is not yet settled on +> L1; wait for settlement and retry. + +## Claim everything for an exit run + +`claim-all.sh` reads an exit tool config file and claims every pending deposit for every +address it tracks: the EOAs in `/step-b-eoa-balances.json` plus the config's +`exitAddress` (where the smart-contract-locked funds land). `outputDir`, `l1RpcUrl`, +`l1BridgeAddress` and the local `signerConfig` keystore are all read from the config, so a +plain invocation usually needs no extra flags. + +```bash +# Preview every claim without sending any transaction: +DRY_RUN=1 ./claim-all.sh + +# Claim everything using the default config (tmp/exit_certificate-kurtosis.json): +./claim-all.sh + +# A different config, against a remote claimer, without the up-front prompt: +CLAIMER_URL=http://10.0.0.5:7080 ASSUME_YES=1 ./claim-all.sh tmp/exit_certificate-cardona.json +``` + +| Env var | Required | Description | +| ------- | -------- | ----------- | +| `CLAIMER_URL` | no (default `127.0.0.1:7080`) | Claimer base URL; a missing scheme is assumed `http://`. | +| `L1_RPC_URL` | no (default config `.l1RpcUrl`) | L1 RPC endpoint hosting `AgglayerBridge`. | +| `BRIDGE_ADDRESS` | no (default config `.l1BridgeAddress`) | `AgglayerBridge` contract address on L1. | +| `PRIVATE_KEY` / `KEYSTORE` (+ `KEYSTORE_PASSWORD`) | no | Override the config's `signerConfig` signer. | +| `DRY_RUN` | no | `1` → only print params and the cast command for each claim. | +| `ASSUME_YES` | no | `1` → skip the single up-front confirmation prompt. | + +The script confirms once for the whole batch, then runs each `claim-asset.sh` non-interactively. +A failed claim (e.g. a `409` for an unsettled root) is logged as a warning and the run +continues; the script exits non-zero if any claim failed. diff --git a/tools/exit_certificate_claimer/scripts/claim-all.sh b/tools/exit_certificate_claimer/scripts/claim-all.sh new file mode 100755 index 000000000..9ae4381b5 --- /dev/null +++ b/tools/exit_certificate_claimer/scripts/claim-all.sh @@ -0,0 +1,311 @@ +#!/usr/bin/env bash +# +# claim-all.sh — claim every pending bridge exit for all addresses tracked by an +# exit_certificate run, by talking to the running exit_certificate_claimer service. +# +# For each address it enumerates the address's deposits via GET /bridges and submits +# AgglayerBridge.claimAsset for each one through the sibling claim-asset.sh. +# +# The set of addresses is: +# - every EOA listed in /step-b-eoa-balances.json, and +# - the config's exitAddress (where the smart-contract-locked funds are sent). +# +# Usage: +# ./claim-all.sh [config_file] +# +# Arguments: +# config_file exit tool config (default: tmp/exit_certificate-kurtosis.json) +# +# Environment: +# CLAIMER_URL Base URL of the claimer service (default: 127.0.0.1:7080). +# A missing scheme is assumed to be http://. +# L1_RPC_URL L1 RPC endpoint (default: config .l1RpcUrl). +# BRIDGE_ADDRESS AgglayerBridge address on L1 (default: config .l1BridgeAddress). +# Signing (override the config-derived signer): +# PRIVATE_KEY raw hex signing key for the claimAsset transactions +# KEYSTORE path to an encrypted keystore JSON +# KEYSTORE_PASSWORD password for KEYSTORE +# DRY_RUN When 1, only print the parameters / cast command for each claim. +# ASSUME_YES When 1, skip the single up-front confirmation prompt. +# +# Examples: +# ./claim-all.sh +# ./claim-all.sh tmp/exit_certificate-cardona.json +# DRY_RUN=1 ./claim-all.sh +# CLAIMER_URL=http://10.0.0.5:7080 ASSUME_YES=1 ./claim-all.sh +# +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CLAIM_ASSET="${SCRIPT_DIR}/claim-asset.sh" + +CONFIG_FILE="${1:-tmp/exit_certificate-kurtosis.json}" +CLAIMER_URL="${CLAIMER_URL:-127.0.0.1:7080}" +DRY_RUN="${DRY_RUN:-0}" +ASSUME_YES="${ASSUME_YES:-0}" + +usage() { + cat >&2 <<-'EOF' + Usage: claim-all.sh [config_file] + + Arguments: + config_file exit tool config (default: tmp/exit_certificate-kurtosis.json) + + Environment variables: + CLAIMER_URL Base URL of the claimer service (default: 127.0.0.1:7080). + A missing scheme is assumed to be http://. + L1_RPC_URL L1 RPC endpoint (default: config .l1RpcUrl). + BRIDGE_ADDRESS AgglayerBridge address on L1 (default: config .l1BridgeAddress). + PRIVATE_KEY Raw hex signing key for the claimAsset transactions. + KEYSTORE Path to an encrypted keystore JSON (alternative to PRIVATE_KEY). + KEYSTORE_PASSWORD Password for KEYSTORE. + If none of the above signer vars are set, the config's local + signerConfig keystore is used. + DRY_RUN When 1, only print the parameters / cast command for each claim. + ASSUME_YES When 1, skip the up-front confirmation prompt. + EOF + exit 2 +} + +if [ "${1:-}" = "-h" ] || [ "${1:-}" = "--help" ]; then + usage +fi + +for bin in curl jq; do + if ! command -v "$bin" >/dev/null 2>&1; then + echo "error: required dependency '$bin' not found in PATH" >&2 + exit 1 + fi +done + +[ -f "$CONFIG_FILE" ] || { + echo "error: config file '$CONFIG_FILE' not found" >&2 + exit 1 +} +[ -x "$CLAIM_ASSET" ] || { + echo "error: claim-asset.sh not found or not executable at '$CLAIM_ASSET'" >&2 + exit 1 +} + +# A bare host:port (no scheme) is treated as http://. +case "$CLAIMER_URL" in + http://* | https://*) ;; + *) CLAIMER_URL="http://${CLAIMER_URL}" ;; +esac +API_BASE="${CLAIMER_URL%/}/claimer/v1" + +# Paths inside the config (outputDir, keystore) are relative to the config's directory. +CONFIG_DIR="$(cd "$(dirname "$CONFIG_FILE")" && pwd)" +resolve_path() { + # Echo $1 unchanged if absolute, otherwise anchored at the config directory. + case "$1" in + /*) printf '%s' "$1" ;; + *) printf '%s/%s' "$CONFIG_DIR" "$1" ;; + esac +} + +EXIT_ADDRESS="$(jq -r '.exitAddress // empty' "$CONFIG_FILE")" +OUTPUT_DIR_RAW="$(jq -r '.options.outputDir // empty' "$CONFIG_FILE")" +L1_RPC_URL="${L1_RPC_URL:-$(jq -r '.l1RpcUrl // empty' "$CONFIG_FILE")}" +BRIDGE_ADDRESS="${BRIDGE_ADDRESS:-$(jq -r '.l1BridgeAddress // empty' "$CONFIG_FILE")}" + +[ -n "$OUTPUT_DIR_RAW" ] || { + echo "error: config is missing .options.outputDir" >&2 + exit 1 +} +OUTPUT_DIR="$(resolve_path "$OUTPUT_DIR_RAW")" +EOA_FILE="${OUTPUT_DIR}/step-b-eoa-balances.json" +[ -f "$EOA_FILE" ] || { + echo "error: EOA balances file '$EOA_FILE' not found (run the exit tool first)" >&2 + exit 1 +} + +# Resolve the signer for claim-asset.sh: an explicit PRIVATE_KEY/KEYSTORE env wins; +# otherwise fall back to the config's local signerConfig keystore. +declare -a signer_env=() +if [ -n "${PRIVATE_KEY:-}" ]; then + signer_env=(PRIVATE_KEY="$PRIVATE_KEY") +elif [ -n "${KEYSTORE:-}" ]; then + signer_env=(KEYSTORE="$KEYSTORE" KEYSTORE_PASSWORD="${KEYSTORE_PASSWORD:-}") +else + signer_method="$(jq -r '.signerConfig.Method // empty' "$CONFIG_FILE")" + if [ "$signer_method" = "local" ]; then + ks_path="$(jq -r '.signerConfig.Path // empty' "$CONFIG_FILE")" + ks_pass="$(jq -r '.signerConfig.Password // empty' "$CONFIG_FILE")" + [ -n "$ks_path" ] || { + echo "error: config signerConfig.Method is 'local' but Path is empty" >&2 + exit 1 + } + ks_path="$(resolve_path "$ks_path")" + [ -f "$ks_path" ] || { + echo "error: signer keystore '$ks_path' not found" >&2 + exit 1 + } + signer_env=(KEYSTORE="$ks_path" KEYSTORE_PASSWORD="$ks_pass") + else + echo "error: no signer available — set PRIVATE_KEY or KEYSTORE, or use a 'local' signerConfig" >&2 + exit 1 + fi +fi + +# Collect the addresses to claim for: every EOA from step-b-eoa-balances.json, then the +# exitAddress (claimed last, since it receives the smart-contract-locked funds). +# step-b-eoa-balances.json is an array of {address,...}; tolerate a plain array of address +# strings as well. Lower-cased and de-duplicated. +declare -a ADDRESSES=() +declare -A seen=() +add_address() { + local a + a="$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')" + [[ "$a" =~ ^0x[0-9a-f]{40}$ ]] || return 0 + [ -n "${seen[$a]:-}" ] && return 0 + seen[$a]=1 + ADDRESSES+=("$a") +} + +# Normalize the exit address and keep it out of the EOA list so it is always claimed last. +EXIT_ADDRESS_LC="" +if [ -n "$EXIT_ADDRESS" ]; then + EXIT_ADDRESS_LC="$(printf '%s' "$EXIT_ADDRESS" | tr '[:upper:]' '[:lower:]')" + if [[ "$EXIT_ADDRESS_LC" =~ ^0x[0-9a-f]{40}$ ]]; then + seen[$EXIT_ADDRESS_LC]=1 + else + EXIT_ADDRESS_LC="" + fi +fi + +while IFS= read -r addr; do + add_address "$addr" +done < <(jq -r '.[] | if type == "object" then .address else . end' "$EOA_FILE") + +[ "${#ADDRESSES[@]}" -gt 0 ] || [ -n "$EXIT_ADDRESS_LC" ] || { + echo "error: no addresses to claim for" >&2 + exit 1 +} + +echo "Claimer: ${CLAIMER_URL}" +echo "L1 RPC: ${L1_RPC_URL:-}" +echo "Bridge address: ${BRIDGE_ADDRESS:-}" +echo "Output dir: ${OUTPUT_DIR}" +echo "Exit address: ${EXIT_ADDRESS:-}" +echo "Addresses: ${#ADDRESSES[@]} EOA(s)$([ -n "$EXIT_ADDRESS_LC" ] && echo ' + exit address (claimed last)')" +echo "Mode: $([ "$DRY_RUN" = "1" ] && echo 'DRY_RUN (no transactions)' || echo 'SUBMIT')" +echo + +# Each claim is confirmed individually below (set ASSUME_YES=1 to skip every prompt). +if [ "$DRY_RUN" != "1" ] && [ "$ASSUME_YES" != "1" ]; then + echo "(you will be asked to confirm each claim; set ASSUME_YES=1 to skip the prompts)" + echo +fi + +# Fetch the deposit_count list for a destination address via GET /bridges. +fetch_deposit_counts() { + local dest="$1" response http_code body + response="$(curl -sS -w $'\n%{http_code}' \ + --get "${API_BASE}/bridges" \ + --data-urlencode "dest_address=${dest}")" || return 1 + http_code="${response##*$'\n'}" + body="${response%$'\n'*}" + if [ "$http_code" != "200" ]; then + echo " warning: /bridges returned HTTP ${http_code} for ${dest}" >&2 + echo "$body" | jq -r '.error // .' >&2 2>/dev/null || true + return 1 + fi + echo "$body" | jq -r '.bridges[]?.deposit_count' +} + +total_claims=0 +total_ok=0 +total_fail=0 +total_skipped=0 + +# Run-summary stats, populated by claim_for_address. +eoa_with_bridges=0 # EOAs that had at least one bridge exit +exit_bridge_count=-1 # bridge exits for the exit address (-1 = no exit address) +declare -a multi_bridge_addrs=() # "address (count)" for any address with >1 bridge exit + +# Claim every pending deposit for a single address. When warn_if_empty=1, the address is +# the exit address (expected to hold the smart-contract-locked funds), so the absence of +# bridge exits is reported as a warning. +claim_for_address() { + local addr="$1" warn_if_empty="${2:-0}" dc n + echo "=== ${addr} ===" + mapfile -t deposits < <(fetch_deposit_counts "$addr" || true) + n="${#deposits[@]}" + if [ "$warn_if_empty" = "1" ]; then + exit_bridge_count="$n" + elif [ "$n" -gt 0 ]; then + eoa_with_bridges=$((eoa_with_bridges + 1)) + fi + [ "$n" -gt 1 ] && multi_bridge_addrs+=("${addr} (${n})") + if [ "$n" -eq 0 ]; then + if [ "$warn_if_empty" = "1" ]; then + echo " warning: no bridge exits found for exit address ${addr}" >&2 + else + echo " no bridge exits" + fi + echo + return 0 + fi + echo " ${n} bridge exit(s): ${deposits[*]}" + for dc in "${deposits[@]}"; do + echo " --- deposit_count=${dc} ---" + # Confirm each claim individually (unless DRY_RUN or ASSUME_YES). + if [ "$DRY_RUN" != "1" ] && [ "$ASSUME_YES" != "1" ]; then + read -r -p " Submit claimAsset for ${addr} deposit_count=${dc}? [y/N] " reply + case "$reply" in + y | Y | yes | YES) ;; + *) + total_skipped=$((total_skipped + 1)) + echo " skipped." + continue + ;; + esac + fi + total_claims=$((total_claims + 1)) + if env "${signer_env[@]}" \ + CLAIMER_URL="$CLAIMER_URL" \ + L1_RPC_URL="$L1_RPC_URL" \ + BRIDGE_ADDRESS="$BRIDGE_ADDRESS" \ + DRY_RUN="$DRY_RUN" \ + ASSUME_YES=1 \ + "$CLAIM_ASSET" "$addr" "$dc"; then + total_ok=$((total_ok + 1)) + else + total_fail=$((total_fail + 1)) + echo " ----------------------------------------------------" + echo " warning: claim failed for ${addr} deposit_count=${dc}" >&2 + fi + done + echo +} + +for addr in "${ADDRESSES[@]}"; do + claim_for_address "$addr" +done + +# Claim the exit address last, warning if it turns out to have no bridge exits. +if [ -n "$EXIT_ADDRESS_LC" ]; then + claim_for_address "$EXIT_ADDRESS_LC" 1 +fi + +echo "===================== Summary =====================" +echo "EOAs: ${#ADDRESSES[@]} total, ${eoa_with_bridges} with bridge exits" +if [ -n "$EXIT_ADDRESS_LC" ]; then + if [ "$exit_bridge_count" -gt 0 ]; then + echo "Exit address: ${EXIT_ADDRESS_LC} — ${exit_bridge_count} bridge exit(s)" + else + echo "Exit address: ${EXIT_ADDRESS_LC} — no bridge exits" + fi +else + echo "Exit address: " +fi +if [ "${#multi_bridge_addrs[@]}" -gt 0 ]; then + echo "Addresses with >1 bridge exit:" + for a in "${multi_bridge_addrs[@]}"; do + echo " - ${a}" + done +fi +echo "Claims: submitted=${total_claims} ok=${total_ok} failed=${total_fail} skipped=${total_skipped}" +echo "===================================================" +[ "$total_fail" -eq 0 ] diff --git a/tools/exit_certificate_claimer/scripts/claim-asset.sh b/tools/exit_certificate_claimer/scripts/claim-asset.sh new file mode 100755 index 000000000..13cd8fefb --- /dev/null +++ b/tools/exit_certificate_claimer/scripts/claim-asset.sh @@ -0,0 +1,239 @@ +#!/usr/bin/env bash +# +# claim-asset.sh — fetch the claimAsset parameters for a single bridge exit from the +# exit_certificate_claimer service and submit AgglayerBridge.claimAsset on L1. +# +# Usage: +# ./claim-asset.sh +# +# Environment: +# CLAIMER_URL Base URL of the claimer service (default: http://localhost:8080) +# L1_RPC_URL L1 RPC endpoint where AgglayerBridge lives (required to send) +# BRIDGE_ADDRESS AgglayerBridge contract address on L1 (required to send) +# Signing (one of the following is required to send): +# PRIVATE_KEY Raw hex signing key for the claimAsset transaction +# KEYSTORE Path to an encrypted keystore JSON; unlocked with the password below +# KEYSTORE_PASSWORD Password for KEYSTORE (read interactively if neither var is set) +# KEYSTORE_PASSWORD_FILE File containing the password for KEYSTORE +# DRY_RUN When set to 1, only print the parameters and the cast command (no tx) +# ASSUME_YES When set to 1, skip the interactive confirmation prompt +# +# Examples: +# DRY_RUN=1 ./claim-asset.sh 0xAbC...001 42 +# L1_RPC_URL=http://localhost:8545 BRIDGE_ADDRESS=0xBridge... PRIVATE_KEY=0xabc... \ +# ./claim-asset.sh 0xAbC...001 42 +# +set -euo pipefail + +CLAIMER_URL="${CLAIMER_URL:-http://localhost:8080}" +API_BASE="${CLAIMER_URL%/}/claimer/v1" +DRY_RUN="${DRY_RUN:-0}" +ASSUME_YES="${ASSUME_YES:-0}" + +# AgglayerBridge.claimAsset selector signature. +CLAIM_ASSET_SIG="claimAsset(bytes32[32],bytes32[32],uint256,bytes32,bytes32,uint32,address,uint32,address,uint256,bytes)" + +usage() { + echo "Usage: $0 " >&2 + echo "" >&2 + echo "Environment variables:" >&2 + echo " CLAIMER_URL base URL of the claimer service (default: http://localhost:8080)" >&2 + echo " L1_RPC_URL L1 RPC endpoint where AgglayerBridge lives (required to submit)" >&2 + echo " BRIDGE_ADDRESS AgglayerBridge contract address on L1 (required to submit)" >&2 + echo " Signing (one is required to submit):" >&2 + echo " PRIVATE_KEY raw hex signing key for the claimAsset transaction" >&2 + echo " KEYSTORE path to an encrypted keystore JSON (unlocked with the password below)" >&2 + echo " KEYSTORE_PASSWORD password for KEYSTORE (read interactively if neither var is set)" >&2 + echo " KEYSTORE_PASSWORD_FILE file containing the password for KEYSTORE" >&2 + echo " DRY_RUN=1 only print params and the cast command (no tx)" >&2 + echo " ASSUME_YES=1 skip the interactive confirmation prompt" >&2 + exit 2 +} + +for bin in curl jq; do + if ! command -v "$bin" >/dev/null 2>&1; then + echo "error: required dependency '$bin' not found in PATH" >&2 + exit 1 + fi +done + +[ "$#" -eq 2 ] || usage +DEST_ADDRESS="$1" +DEPOSIT_COUNT="$2" + +if ! [[ "$DEST_ADDRESS" =~ ^0x[0-9a-fA-F]{40}$ ]]; then + echo "error: '$DEST_ADDRESS' is not a valid hex address" >&2 + exit 1 +fi +if ! [[ "$DEPOSIT_COUNT" =~ ^[0-9]+$ ]]; then + echo "error: deposit_count '$DEPOSIT_COUNT' must be a non-negative integer" >&2 + exit 1 +fi + +# Fetch /claim-params for the selected deposit, capturing body and HTTP status. +response="$(curl -sS -w $'\n%{http_code}' \ + --get "${API_BASE}/claim-params" \ + --data-urlencode "dest_address=${DEST_ADDRESS}" \ + --data-urlencode "deposit_count=${DEPOSIT_COUNT}")" +http_code="${response##*$'\n'}" +body="${response%$'\n'*}" + +if [ "$http_code" != "200" ]; then + echo "error: claimer returned HTTP ${http_code}" >&2 + if [ "$http_code" = "409" ]; then + echo " the certificate's local exit root is not yet settled on L1." >&2 + fi + echo "$body" | jq -r '.error // .' >&2 2>/dev/null || echo "$body" >&2 + exit 1 +fi + +# Persist the raw response for later inspection and log its location. +params_file="/tmp/claim-params-${DEST_ADDRESS}-${DEPOSIT_COUNT}.json" +echo "$body" >"$params_file" + + +n_claims="$(echo "$body" | jq '.claims | length')" +if [ "$n_claims" -eq 0 ]; then + echo "error: no claim found for deposit_count=${DEPOSIT_COUNT} at ${DEST_ADDRESS}" >&2 + exit 1 +fi +if [ "$n_claims" -ne 1 ]; then + echo "error: expected exactly 1 claim, got ${n_claims} (refine deposit_count)" >&2 + exit 1 +fi + +claim="$(echo "$body" | jq '.claims[0]')" + +# Extract every claimAsset argument. Fixed bytes32[32] arrays are rendered as "[0x..,0x..]". +smt_local="$(echo "$claim" | jq -r '.smt_proof_local_exit_root | "[" + join(",") + "]"')" +smt_rollup="$(echo "$claim" | jq -r '.smt_proof_rollup_exit_root | "[" + join(",") + "]"')" +global_index="$(echo "$claim" | jq -r '.global_index')" +mainnet_exit_root="$(echo "$claim" | jq -r '.mainnet_exit_root')" +rollup_exit_root="$(echo "$claim" | jq -r '.rollup_exit_root')" +origin_network="$(echo "$claim" | jq -r '.origin_network')" +origin_token="$(echo "$claim" | jq -r '.origin_token_address')" +destination_network="$(echo "$claim" | jq -r '.destination_network')" +destination_address="$(echo "$claim" | jq -r '.destination_address')" +amount="$(echo "$claim" | jq -r '.amount')" +metadata="$(echo "$claim" | jq -r '.metadata')" + +echo "claimAsset parameters for deposit_count=${DEPOSIT_COUNT}:" +echo " global_index: ${global_index}" +echo " mainnet_exit_root: ${mainnet_exit_root}" +echo " rollup_exit_root: ${rollup_exit_root}" +echo " origin_network: ${origin_network}" +echo " origin_token: ${origin_token}" +echo " destination_network: ${destination_network}" +echo " destination_address: ${destination_address}" +echo " amount: ${amount}" +echo " metadata: ${metadata}" +echo " l1_info_tree_index: $(echo "$claim" | jq -r '.l1_info_tree_index')" +echo +echo " --- 🔎 saved claim params to: ${params_file}" >&2 +echo + +# Assemble the cast send argument vector once; reused for the printed command and execution. +cast_args=( + "$CLAIM_ASSET_SIG" + "$smt_local" + "$smt_rollup" + "$global_index" + "$mainnet_exit_root" + "$rollup_exit_root" + "$origin_network" + "$origin_token" + "$destination_network" + "$destination_address" + "$amount" + "$metadata" +) + +if [ "$DRY_RUN" = "1" ]; then + echo "DRY_RUN=1 — not submitting. Equivalent cast command:" + if [ -n "${KEYSTORE:-}" ]; then + signer_display='--keystore "'"$KEYSTORE"'" --password ""' + else + signer_display='--private-key ""' + fi + printf 'cast send "%s" \\\n' "${BRIDGE_ADDRESS:-}" + printf ' --rpc-url "%s" %s \\\n' "${L1_RPC_URL:-}" "$signer_display" + printf " '%s' \\\\\n" "${cast_args[0]}" + for ((i = 1; i < ${#cast_args[@]}; i++)); do + printf " '%s'" "${cast_args[$i]}" + [ "$i" -lt $((${#cast_args[@]} - 1)) ] && printf ' \\' + printf '\n' + done + exit 0 +fi + +# Submission path: validate signing prerequisites. +if ! command -v cast >/dev/null 2>&1; then + echo "error: 'cast' (foundry) not found in PATH; install foundry or use DRY_RUN=1" >&2 + exit 1 +fi +: "${L1_RPC_URL:?error: L1_RPC_URL is required to submit the transaction}" +: "${BRIDGE_ADDRESS:?error: BRIDGE_ADDRESS is required to submit the transaction}" + +# Resolve the signer: an encrypted keystore (unlocked with a password) takes precedence +# over a raw PRIVATE_KEY. +signer_args=() +if [ -n "${KEYSTORE:-}" ]; then + [ -f "$KEYSTORE" ] || { + echo "error: KEYSTORE file '$KEYSTORE' not found" >&2 + exit 1 + } + signer_args=(--keystore "$KEYSTORE") + if [ -n "${KEYSTORE_PASSWORD_FILE:-}" ]; then + [ -f "$KEYSTORE_PASSWORD_FILE" ] || { + echo "error: KEYSTORE_PASSWORD_FILE '$KEYSTORE_PASSWORD_FILE' not found" >&2 + exit 1 + } + signer_args+=(--password-file "$KEYSTORE_PASSWORD_FILE") + else + if [ -z "${KEYSTORE_PASSWORD:-}" ]; then + read -r -s -p "Keystore password: " KEYSTORE_PASSWORD + echo >&2 + fi + signer_args+=(--password "$KEYSTORE_PASSWORD") + fi +elif [ -n "${PRIVATE_KEY:-}" ]; then + signer_args=(--private-key "$PRIVATE_KEY") +else + echo "error: provide KEYSTORE (encrypted keystore) or PRIVATE_KEY to sign the transaction" >&2 + exit 1 +fi + +if [ "$ASSUME_YES" != "1" ]; then + read -r -p "Submit claimAsset to ${BRIDGE_ADDRESS} via ${L1_RPC_URL}? [y/N] " reply + case "$reply" in + y | Y | yes | YES) ;; + *) + echo "Aborted." >&2 + exit 1 + ;; + esac +fi + +# Submit and wait for the transaction to be mined. cast send blocks until the +# receipt is available; --json gives us a machine-readable receipt to inspect. +receipt=$(cast send "$BRIDGE_ADDRESS" \ + --rpc-url "$L1_RPC_URL" \ + "${signer_args[@]}" \ + "${cast_args[@]}" \ + --json) + +echo "$receipt" | jq . + +tx_hash=$(echo "$receipt" | jq -r '.transactionHash') +status=$(echo "$receipt" | jq -r '.status') + +# Receipt status is "0x1" on success and "0x0" when the transaction reverted. +case "$status" in + 0x1 | 1) + echo "claimAsset succeeded: $tx_hash" + ;; + *) + echo "error: claimAsset transaction $tx_hash reverted (status=$status)" >&2 + exit 1 + ;; +esac diff --git a/tools/exit_certificate_claimer/scripts/list-bridges.sh b/tools/exit_certificate_claimer/scripts/list-bridges.sh new file mode 100755 index 000000000..09c99d558 --- /dev/null +++ b/tools/exit_certificate_claimer/scripts/list-bridges.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# +# list-bridges.sh — list the bridge exits (deposits) associated with a destination +# address by querying the exit_certificate_claimer service. +# +# Usage: +# ./list-bridges.sh +# +# Environment: +# CLAIMER_URL Base URL of the claimer service (default: http://localhost:8080) +# +# Examples: +# ./list-bridges.sh 0xAbC0000000000000000000000000000000000001 +# CLAIMER_URL=http://10.0.0.5:9090 ./list-bridges.sh 0xAbC...001 +# +set -euo pipefail + +CLAIMER_URL="${CLAIMER_URL:-http://localhost:8080}" +API_BASE="${CLAIMER_URL%/}/claimer/v1" + +usage() { + echo "Usage: $0 " >&2 + echo " CLAIMER_URL (env) defaults to http://localhost:8080" >&2 + exit 2 +} + +for bin in curl jq; do + if ! command -v "$bin" >/dev/null 2>&1; then + echo "error: required dependency '$bin' not found in PATH" >&2 + exit 1 + fi +done + +[ "$#" -eq 1 ] || usage +DEST_ADDRESS="$1" + +if ! [[ "$DEST_ADDRESS" =~ ^0x[0-9a-fA-F]{40}$ ]]; then + echo "error: '$DEST_ADDRESS' is not a valid hex address" >&2 + exit 1 +fi + +# Fetch /bridges, capturing body and HTTP status separately. +response="$(curl -sS -w $'\n%{http_code}' \ + --get "${API_BASE}/bridges" \ + --data-urlencode "dest_address=${DEST_ADDRESS}")" +http_code="${response##*$'\n'}" +body="${response%$'\n'*}" + +if [ "$http_code" != "200" ]; then + echo "error: claimer returned HTTP ${http_code}" >&2 + echo "$body" | jq -r '.error // .' >&2 2>/dev/null || echo "$body" >&2 + exit 1 +fi + +count="$(echo "$body" | jq '.bridges | length')" +network_id="$(echo "$body" | jq -r '.network_id')" + +echo "Network ID: ${network_id}" +echo "Destination address: ${DEST_ADDRESS}" +echo "Bridge exits found: ${count}" +echo + +if [ "$count" -eq 0 ]; then + echo "No bridge exits associated with this address." + exit 0 +fi + +# Tabular summary; use --claim-params against claim-asset.sh to claim. +echo "$body" | jq -r ' + ["DEPOSIT_COUNT","LEAF_TYPE","ORIGIN_NET","TOKEN","AMOUNT"], + (.bridges[] | [ + (.deposit_count|tostring), + (.leaf_type|tostring), + (.origin_network|tostring), + .origin_token_address, + .amount + ]) | @tsv' | column -t -s $'\t' + +echo +echo "Raw JSON:" +echo "$body" | jq . diff --git a/tools/exit_certificate_claimer/service/certificate.go b/tools/exit_certificate_claimer/service/certificate.go new file mode 100644 index 000000000..20e035774 --- /dev/null +++ b/tools/exit_certificate_claimer/service/certificate.go @@ -0,0 +1,193 @@ +package claimer + +import ( + "encoding/binary" + "encoding/hex" + "encoding/json" + "fmt" + "math/big" + "os" + "strings" + + aggkitcommon "github.com/agglayer/aggkit/common" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" +) + +// decimalBase is the base used to parse decimal amount strings from the certificate. +const decimalBase = 10 + +// tokenInfo mirrors the token_info object of a bridge exit in the signed certificate. +type tokenInfo struct { + OriginNetwork uint32 `json:"origin_network"` + OriginTokenAddress common.Address `json:"origin_token_address"` +} + +// bridgeExit mirrors a single entry of the bridge_exits array in the signed certificate. +type bridgeExit struct { + LeafType string `json:"leaf_type"` + TokenInfo tokenInfo `json:"token_info"` + DestinationNetwork uint32 `json:"dest_network"` + DestinationAddress common.Address `json:"dest_address"` + Amount string `json:"amount"` + Metadata string `json:"metadata"` +} + +// signedCertificate mirrors the fields of exit-certificate-signed.json that this tool needs. +type signedCertificate struct { + NetworkID uint32 `json:"network_id"` + PrevLocalExitRoot common.Hash `json:"prev_local_exit_root"` + NewLocalExitRoot common.Hash `json:"new_local_exit_root"` + BridgeExits []bridgeExit `json:"bridge_exits"` + L1InfoTreeLeafCount uint32 `json:"l1_info_tree_leaf_count"` +} + +// Certificate is the parsed, validated view of the signed exit certificate. Each bridge exit is +// normalized into a leaf so it can be matched against the local exit tree by leaf hash. +type Certificate struct { + NetworkID uint32 + NewLocalExitRoot common.Hash + Leaves []CertificateLeaf +} + +// CertificateLeaf is a single bridge exit normalized to the canonical exit-tree leaf form. +type CertificateLeaf struct { + LeafType uint8 + OriginNetwork uint32 + OriginTokenAddress common.Address + DestinationNetwork uint32 + DestinationAddress common.Address + Amount *big.Int + // MetadataHash is the keccak256 hash of the raw bridge metadata, not the raw metadata itself + // (the certificate stores it already hashed — see Hash). It is used directly as the leaf's + // metadata-hash slot. + MetadataHash []byte +} + +// Hash returns the exit-tree leaf hash of the bridge exit. It mirrors the on-chain bridge leaf +// hashing (bridgesync.Bridge.Hash / bridgesyncerlite.BridgeLeaf.Hash) with one crucial difference: +// those compute the metadata-hash slot as crypto.Keccak256(rawMetadata), whereas the certificate's +// Metadata field is ALREADY that hash. exit_certificate Step I applies crypto.Keccak256 to the raw +// BridgeEvent metadata before storing it in BridgeExit.Metadata (matching aggsender, so that +// agglayer's BridgeExit.Hash matches). We therefore use Metadata directly as the metadata-hash slot +// — re-hashing it here would double-hash and never match the local exit tree. This replicates +// agglayer BridgeExit.Hash, including the empty-metadata → EmptyBytesHash fallback. +func (l CertificateLeaf) Hash() common.Hash { + const ( + uint32ByteSize = 4 + bigIntSize = 32 + ) + origNet := make([]byte, uint32ByteSize) + binary.BigEndian.PutUint32(origNet, l.OriginNetwork) + destNet := make([]byte, uint32ByteSize) + binary.BigEndian.PutUint32(destNet, l.DestinationNetwork) + + metaHash := l.MetadataHash + if len(metaHash) == 0 { + metaHash = aggkitcommon.EmptyBytesHash + } + + amount := l.Amount + if amount == nil { + amount = new(big.Int) + } + var buf [bigIntSize]byte + + return crypto.Keccak256Hash( + []byte{l.LeafType}, + origNet, + l.OriginTokenAddress[:], + destNet, + l.DestinationAddress[:], + amount.FillBytes(buf[:]), + metaHash, + ) +} + +// view converts a leaf into its public representation, enriched with the resolved deposit count. +func (l CertificateLeaf) view(depositCount uint32) BridgeExitView { + return BridgeExitView{ + LeafType: l.LeafType, + OriginNetwork: l.OriginNetwork, + OriginTokenAddress: addrHex(l.OriginTokenAddress), + DestinationNetwork: l.DestinationNetwork, + DestinationAddress: addrHex(l.DestinationAddress), + Amount: bigToString(l.Amount), + Metadata: metadataHex(l.MetadataHash), + DepositCount: depositCount, + } +} + +// LoadCertificate reads and parses the signed exit certificate from disk. +func LoadCertificate(path string) (*Certificate, error) { + raw, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("reading signed certificate %q: %w", path, err) + } + + var sc signedCertificate + if err := json.Unmarshal(raw, &sc); err != nil { + return nil, fmt.Errorf("parsing signed certificate %q: %w", path, err) + } + + cert := &Certificate{ + NetworkID: sc.NetworkID, + NewLocalExitRoot: sc.NewLocalExitRoot, + Leaves: make([]CertificateLeaf, 0, len(sc.BridgeExits)), + } + + for i, be := range sc.BridgeExits { + leafType, err := parseLeafType(be.LeafType) + if err != nil { + return nil, fmt.Errorf("bridge exit %d: %w", i, err) + } + + amount, ok := new(big.Int).SetString(strings.TrimSpace(be.Amount), decimalBase) + if !ok { + return nil, fmt.Errorf("bridge exit %d: invalid amount %q", i, be.Amount) + } + + metadataHash, err := parseMetadata(be.Metadata) + if err != nil { + return nil, fmt.Errorf("bridge exit %d: %w", i, err) + } + + cert.Leaves = append(cert.Leaves, CertificateLeaf{ + LeafType: leafType, + OriginNetwork: be.TokenInfo.OriginNetwork, + OriginTokenAddress: be.TokenInfo.OriginTokenAddress, + DestinationNetwork: be.DestinationNetwork, + DestinationAddress: be.DestinationAddress, + Amount: amount, + MetadataHash: metadataHash, + }) + } + + return cert, nil +} + +// parseLeafType maps the certificate's string leaf type to the numeric form used on-chain. +func parseLeafType(s string) (uint8, error) { + switch s { + case leafTypeTransferStr: + return leafTypeAsset, nil + case leafTypeMessageStr: + return leafTypeMessage, nil + default: + return 0, fmt.Errorf("unknown leaf_type %q", s) + } +} + +// parseMetadata decodes the certificate's metadata hex string (with or without 0x prefix). +// An empty string decodes to empty (not nil) metadata, matching the leaf hashing of an empty blob. +func parseMetadata(s string) ([]byte, error) { + s = strings.TrimPrefix(strings.TrimSpace(s), "0x") + if s == "" { + return []byte{}, nil + } + b, err := hex.DecodeString(s) + if err != nil { + return nil, fmt.Errorf("invalid metadata hex: %w", err) + } + return b, nil +} diff --git a/tools/exit_certificate_claimer/service/certificate_test.go b/tools/exit_certificate_claimer/service/certificate_test.go new file mode 100644 index 000000000..67f9ff738 --- /dev/null +++ b/tools/exit_certificate_claimer/service/certificate_test.go @@ -0,0 +1,106 @@ +package claimer + +import ( + "os" + "path/filepath" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +const sampleSignedCertificate = `{ + "network_id": 1, + "new_local_exit_root": "0x8a644096ff45bf6efc0057b1f42dc52cdbf7bd098f9154f9f72c5fd270a8c519", + "bridge_exits": [ + { + "leaf_type": "Transfer", + "token_info": { + "origin_network": 0, + "origin_token_address": "0x0000000000000000000000000000000000000000" + }, + "dest_network": 0, + "dest_address": "0x0b68058e5b2592b1f472adfe106305295a332a7c", + "amount": "20000005400000000", + "metadata": "c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470" + }, + { + "leaf_type": "Transfer", + "token_info": { + "origin_network": 0, + "origin_token_address": "0x62bf798edae1b7fde524276864757cc424a5c3dd" + }, + "dest_network": 0, + "dest_address": "0x85da99c8a7c2c95964c8efd687e95e632fc533d6", + "amount": "100000000000000000", + "metadata": "0c9cd205d5953a2e073bcc4e1dbb0996d17f6e5d820c69b2d16ae1142d2b004f" + } + ], + "l1_info_tree_leaf_count": 10 +}` + +func writeSampleCert(t *testing.T) string { + t.Helper() + path := filepath.Join(t.TempDir(), "exit-certificate-signed.json") + require.NoError(t, os.WriteFile(path, []byte(sampleSignedCertificate), 0o600)) + return path +} + +func TestLoadCertificate(t *testing.T) { + t.Parallel() + + cert, err := LoadCertificate(writeSampleCert(t)) + require.NoError(t, err) + + require.Equal(t, uint32(1), cert.NetworkID) + require.Equal(t, + common.HexToHash("0x8a644096ff45bf6efc0057b1f42dc52cdbf7bd098f9154f9f72c5fd270a8c519"), + cert.NewLocalExitRoot) + require.Len(t, cert.Leaves, 2) + + first := cert.Leaves[0] + require.Equal(t, leafTypeAsset, first.LeafType) + require.Equal(t, uint32(0), first.OriginNetwork) + require.Equal(t, common.Address{}, first.OriginTokenAddress) + require.Equal(t, uint32(0), first.DestinationNetwork) + require.Equal(t, + common.HexToAddress("0x0b68058e5b2592b1f472adfe106305295a332a7c"), + first.DestinationAddress) + require.Equal(t, "20000005400000000", first.Amount.String()) + require.Len(t, first.MetadataHash, 32) + + // Leaf hashes must be deterministic and distinct. + require.NotEqual(t, first.Hash(), cert.Leaves[1].Hash()) +} + +func TestLoadCertificateErrors(t *testing.T) { + t.Parallel() + + _, err := LoadCertificate(filepath.Join(t.TempDir(), "missing.json")) + require.Error(t, err) + + badPath := filepath.Join(t.TempDir(), "bad.json") + require.NoError(t, os.WriteFile(badPath, []byte(`{"bridge_exits":[{"leaf_type":"Nope"}]}`), 0o600)) + _, err = LoadCertificate(badPath) + require.ErrorContains(t, err, "unknown leaf_type") +} + +func TestParseMetadata(t *testing.T) { + t.Parallel() + + empty, err := parseMetadata("") + require.NoError(t, err) + require.Empty(t, empty) + require.NotNil(t, empty) + + withPrefix, err := parseMetadata("0xabcd") + require.NoError(t, err) + require.Equal(t, []byte{0xab, 0xcd}, withPrefix) + + withoutPrefix, err := parseMetadata("abcd") + require.NoError(t, err) + require.Equal(t, []byte{0xab, 0xcd}, withoutPrefix) + + _, err = parseMetadata("0xzz") + require.Error(t, err) +} diff --git a/tools/exit_certificate_claimer/service/claimer.go b/tools/exit_certificate_claimer/service/claimer.go new file mode 100644 index 000000000..242f1f74a --- /dev/null +++ b/tools/exit_certificate_claimer/service/claimer.go @@ -0,0 +1,220 @@ +package claimer + +import ( + "context" + "errors" + "fmt" + + "github.com/agglayer/aggkit/bridgesync" + "github.com/agglayer/aggkit/l1infotreesync" + "github.com/agglayer/aggkit/log" + exitcertificate "github.com/agglayer/aggkit/tools/exit_certificate" + treetypes "github.com/agglayer/aggkit/tree/types" + "github.com/ethereum/go-ethereum/common" +) + +// LocalExitTreeReader is the subset of LocalExitTree the claimer depends on. *LocalExitTree +// satisfies it; tests can supply a fake. +type LocalExitTreeReader interface { + DepositCount(leafHash common.Hash) (uint32, bool) + Metadata(leafHash common.Hash) ([]byte, bool) + Proof(ctx context.Context, depositCount uint32, localExitRoot common.Hash) (treetypes.Proof, error) +} + +// ErrLocalExitRootNotSettled is returned when the certificate's NewLocalExitRoot cannot be found in +// the rollup exit tree of the selected L1 info tree leaf — i.e. the exit certificate has not been +// settled on L1 yet (or the chosen leaf predates settlement). +var ErrLocalExitRootNotSettled = errors.New("certificate new local exit root not settled in L1 info tree") + +// Claimer assembles claimAsset parameters for the bridge exits of a settled exit certificate, +// combining the signed certificate, the L2 local exit tree, and the L1 Info Tree. +type Claimer struct { + logger *log.Logger + networkID uint32 + cert *Certificate + localTree LocalExitTreeReader + l1 L1InfoTreeQuerier + waitResult *exitcertificate.StepWaitResult +} + +// NewClaimer wires the data sources. networkID defaults to the certificate's network when 0. +// waitResult is the exit_certificate WAIT step output recording the certificate's L1 settlement. +func NewClaimer( + logger *log.Logger, + cert *Certificate, + localTree LocalExitTreeReader, + l1 L1InfoTreeQuerier, + networkID uint32, + waitResult *exitcertificate.StepWaitResult, +) *Claimer { + if networkID == 0 { + networkID = cert.NetworkID + } + return &Claimer{ + logger: logger, + networkID: networkID, + cert: cert, + localTree: localTree, + l1: l1, + waitResult: waitResult, + } +} + +// NetworkID returns the source network the claimer serves. +func (c *Claimer) NetworkID() uint32 { return c.networkID } + +// SettlementWaitResult returns the exit_certificate WAIT step output recording where on L1 the +// certificate settled (the VerifyBatchesTrustedAggregator event and the accompanying L1 Info Tree +// update). It may be nil if no wait result was loaded. +func (c *Claimer) SettlementWaitResult() *exitcertificate.StepWaitResult { return c.waitResult } + +// ListBridges returns the certificate bridge exits destined to destAddr, enriched with the deposit +// count resolved from the local exit tree. +func (c *Claimer) ListBridges(destAddr common.Address) ([]BridgeExitView, error) { + views := make([]BridgeExitView, 0) + for i := range c.cert.Leaves { + leaf := c.cert.Leaves[i] + if leaf.DestinationAddress != destAddr { + continue + } + depositCount, ok := c.localTree.DepositCount(leaf.Hash()) + if !ok { + return nil, fmt.Errorf("error: bridge exit[%d] (leaf %s) not found in local exit tree", + i, leaf.Hash().Hex()) + } + views = append(views, leaf.view(depositCount)) + } + return views, nil +} + +// BuildClaimParams returns the set of claimAsset arguments for the bridge exits destined to +// destAddr. The proofs are anchored to the L1 info tree leaf the certificate settled at (the leaf +// carrying the GER recorded by the WAIT step), not the latest leaf: the certificate's +// NewLocalExitRoot is only present in the rollup exit tree of that settlement leaf, and a later leaf +// would carry a newer rollup exit root that no longer contains it. When depositCount is non-nil only +// the exit with that deposit count is returned (an address may have more than one pending deposit); +// when nil every matching exit is returned. +func (c *Claimer) BuildClaimParams( + ctx context.Context, destAddr common.Address, depositCount *uint32, +) ([]ClaimAssetParams, error) { + leaf, err := c.settlementLeaf() + if err != nil { + return nil, err + } + + // Verify the certificate's new local exit root is the one settled for this network in the + // selected L1 info tree leaf's rollup exit tree. + settledLER, err := c.l1.GetLocalExitRoot(ctx, c.networkID, leaf.RollupExitRoot) + if err != nil { + return nil, fmt.Errorf("reading local exit root for network %d at L1 info leaf %d: %w", + c.networkID, leaf.L1InfoTreeIndex, err) + } + if settledLER != c.cert.NewLocalExitRoot { + return nil, fmt.Errorf("%w: network %d L1 info leaf %d has local exit root %s, certificate has %s", + ErrLocalExitRootNotSettled, c.networkID, leaf.L1InfoTreeIndex, + settledLER.Hex(), c.cert.NewLocalExitRoot.Hex()) + } + + rollupProof, err := c.l1.GetRollupExitTreeMerkleProof(ctx, c.networkID, leaf.RollupExitRoot) + if err != nil { + return nil, fmt.Errorf("building rollup exit tree proof for network %d: %w", c.networkID, err) + } + + claims := make([]ClaimAssetParams, 0) + for i := range c.cert.Leaves { + certLeaf := c.cert.Leaves[i] + if certLeaf.DestinationAddress != destAddr { + continue + } + + leafHash := certLeaf.Hash() + dc, ok := c.localTree.DepositCount(leafHash) + if !ok { + return nil, fmt.Errorf("bridge exit %d (leaf %s) not found in local exit tree", + i, leafHash.Hex()) + } + + if depositCount != nil && dc != *depositCount { + continue + } + + // The claim needs the raw metadata (the bridge contract hashes it itself); the certificate + // only carries its hash, so take the on-chain bytes recorded in the local exit tree. + rawMetadata, ok := c.localTree.Metadata(leafHash) + if !ok { + return nil, fmt.Errorf("bridge exit %d (leaf %s) has no metadata in local exit tree", + i, leafHash.Hex()) + } + + localProof, err := c.localTree.Proof(ctx, dc, c.cert.NewLocalExitRoot) + if err != nil { + return nil, fmt.Errorf("building local exit tree proof for deposit %d: %w", dc, err) + } + + globalIndex := bridgesync.GenerateGlobalIndexForNetworkID(c.networkID, dc) + + claims = append(claims, ClaimAssetParams{ + SmtProofLocalExitRoot: proofToHex(localProof), + SmtProofRollupExitRoot: proofToHex(rollupProof), + GlobalIndex: globalIndex.String(), + MainnetExitRoot: leaf.MainnetExitRoot.Hex(), + RollupExitRoot: leaf.RollupExitRoot.Hex(), + OriginNetwork: certLeaf.OriginNetwork, + OriginTokenAddress: addrHex(certLeaf.OriginTokenAddress), + DestinationNetwork: certLeaf.DestinationNetwork, + DestinationAddress: addrHex(certLeaf.DestinationAddress), + Amount: bigToString(certLeaf.Amount), + Metadata: metadataHex(rawMetadata), + LeafType: certLeaf.LeafType, + DepositCount: dc, + L1InfoTreeIndex: leaf.L1InfoTreeIndex, + }) + } + + return claims, nil +} + +// settlementLeaf returns the L1 info tree leaf the certificate settled at — the leaf carrying the +// Global Exit Root recorded by the WAIT step (keccak256(mainnetExitRoot, rollupExitRoot)). Claim +// proofs must be anchored to this leaf rather than the latest one (see BuildClaimParams). +func (c *Claimer) settlementLeaf() (*l1infotreesync.L1InfoTreeLeaf, error) { + if c.waitResult == nil { + return nil, errors.New("wait result is nil; cannot locate the settlement L1 info tree leaf") + } + ger, err := SettlementGER(c.waitResult) + if err != nil { + return nil, err + } + leaf, err := c.l1.GetInfoByGlobalExitRoot(ger) + if err != nil { + return nil, fmt.Errorf("reading settlement L1 info tree leaf for GER %s: %w", ger.Hex(), err) + } + return leaf, nil +} + +// Check verifies the claimer's data sources are consistent with the certificate's recorded L1 +// settlement. It looks up, in the L1 info tree, the local exit root settled for this network in the +// rollup exit tree captured by the WAIT step (waitResult.UpdateL1InfoTree.RollupExitRoot) and +// confirms it equals the certificate's NewLocalExitRoot. It returns ErrLocalExitRootNotSettled when +// they differ — the certificate has not been settled on L1 (or the recorded settlement is stale). +func (c *Claimer) Check(ctx context.Context) error { + if c.waitResult == nil || c.waitResult.UpdateL1InfoTree == nil { + return errors.New("wait result has no updateL1InfoTree event; cannot verify L1 settlement") + } + rollupExitRoot := c.waitResult.UpdateL1InfoTree.RollupExitRoot + + settledLER, err := c.l1.GetLocalExitRoot(ctx, c.networkID, rollupExitRoot) + if err != nil { + return fmt.Errorf("reading local exit root for network %d at settlement rollup exit root %s: %w", + c.networkID, rollupExitRoot.Hex(), err) + } + if settledLER != c.cert.NewLocalExitRoot { + return fmt.Errorf("%w: network %d settlement rollup exit root %s has local exit root %s, certificate has %s", + ErrLocalExitRootNotSettled, c.networkID, rollupExitRoot.Hex(), + settledLER.Hex(), c.cert.NewLocalExitRoot.Hex()) + } + + c.logger.Infof("✅ settlement check OK: network %d new local exit root %s settled in rollup exit root %s", + c.networkID, c.cert.NewLocalExitRoot.Hex(), rollupExitRoot.Hex()) + return nil +} diff --git a/tools/exit_certificate_claimer/service/claimer_check_test.go b/tools/exit_certificate_claimer/service/claimer_check_test.go new file mode 100644 index 000000000..fb93dca94 --- /dev/null +++ b/tools/exit_certificate_claimer/service/claimer_check_test.go @@ -0,0 +1,114 @@ +package claimer + +import ( + "context" + "testing" + + "github.com/agglayer/aggkit/l1infotreesync" + "github.com/agglayer/aggkit/log" + exitcertificate "github.com/agglayer/aggkit/tools/exit_certificate" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +func TestCheckOK(t *testing.T) { + t.Parallel() + + cert, err := LoadCertificate(writeSampleCert(t)) + require.NoError(t, err) + + claimer, _ := buildTestClaimer(t, cert.NewLocalExitRoot) + require.NoError(t, claimer.Check(context.Background())) +} + +func TestCheckNotSettled(t *testing.T) { + t.Parallel() + + claimer, _ := buildTestClaimer(t, common.HexToHash("0xdeadbeef")) + require.ErrorIs(t, claimer.Check(context.Background()), ErrLocalExitRootNotSettled) +} + +func TestCheckNilWaitResult(t *testing.T) { + t.Parallel() + + cert, err := LoadCertificate(writeSampleCert(t)) + require.NoError(t, err) + + claimer := NewClaimer(log.GetDefaultLogger(), cert, &fakeLocalTree{}, &fakeL1{}, 0, nil) + require.ErrorContains(t, claimer.Check(context.Background()), "no updateL1InfoTree event") +} + +func TestBuildClaimParamsDepositCountFilter(t *testing.T) { + t.Parallel() + + cert, err := LoadCertificate(writeSampleCert(t)) + require.NoError(t, err) + destAddr := cert.Leaves[0].DestinationAddress + + claimer, _ := buildTestClaimer(t, cert.NewLocalExitRoot) + + // Leaf 0 maps to deposit count 5 (offset +5 in buildTestClaimer); a non-matching filter yields none. + other := uint32(99) + claims, err := claimer.BuildClaimParams(context.Background(), destAddr, &other) + require.NoError(t, err) + require.Empty(t, claims) + + // The exact deposit count returns just that exit. + match := uint32(5) + claims, err = claimer.BuildClaimParams(context.Background(), destAddr, &match) + require.NoError(t, err) + require.Len(t, claims, 1) + require.Equal(t, uint32(5), claims[0].DepositCount) +} + +func TestBuildClaimParamsNilWaitResult(t *testing.T) { + t.Parallel() + + cert, err := LoadCertificate(writeSampleCert(t)) + require.NoError(t, err) + + claimer := NewClaimer(log.GetDefaultLogger(), cert, &fakeLocalTree{}, &fakeL1{}, 0, nil) + _, err = claimer.BuildClaimParams(context.Background(), cert.Leaves[0].DestinationAddress, nil) + require.ErrorContains(t, err, "wait result is nil") +} + +func TestListBridgesLeafNotFound(t *testing.T) { + t.Parallel() + + cert, err := LoadCertificate(writeSampleCert(t)) + require.NoError(t, err) + + // An empty local tree resolves no deposit counts. + l1 := &fakeL1{ + leaf: &l1infotreesync.L1InfoTreeLeaf{}, + } + waitResult := &exitcertificate.StepWaitResult{ + UpdateL1InfoTree: &exitcertificate.L1InfoTreeUpdate{}, + } + claimer := NewClaimer(log.GetDefaultLogger(), cert, &fakeLocalTree{}, l1, 0, waitResult) + + _, err = claimer.ListBridges(cert.Leaves[0].DestinationAddress) + require.ErrorContains(t, err, "not found in local exit tree") +} + +func TestNetworkIDOverride(t *testing.T) { + t.Parallel() + + cert, err := LoadCertificate(writeSampleCert(t)) + require.NoError(t, err) + + // An explicit non-zero networkID overrides the certificate's network_id. + claimer := NewClaimer(log.GetDefaultLogger(), cert, &fakeLocalTree{}, &fakeL1{}, 42, nil) + require.Equal(t, uint32(42), claimer.NetworkID()) +} + +func TestSettlementWaitResultGetter(t *testing.T) { + t.Parallel() + + cert, err := LoadCertificate(writeSampleCert(t)) + require.NoError(t, err) + + waitResult := &exitcertificate.StepWaitResult{} + claimer := NewClaimer(log.GetDefaultLogger(), cert, &fakeLocalTree{}, &fakeL1{}, 0, waitResult) + require.Same(t, waitResult, claimer.SettlementWaitResult()) +} diff --git a/tools/exit_certificate_claimer/service/claimer_errors_test.go b/tools/exit_certificate_claimer/service/claimer_errors_test.go new file mode 100644 index 000000000..7c35925f5 --- /dev/null +++ b/tools/exit_certificate_claimer/service/claimer_errors_test.go @@ -0,0 +1,127 @@ +package claimer + +import ( + "context" + "errors" + "testing" + + "github.com/agglayer/aggkit/l1infotreesync" + "github.com/agglayer/aggkit/log" + exitcertificate "github.com/agglayer/aggkit/tools/exit_certificate" + treetypes "github.com/agglayer/aggkit/tree/types" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +// cfgL1 is a configurable L1InfoTreeQuerier whose methods can be made to error or return canned data. +type cfgL1 struct { + leaf *l1infotreesync.L1InfoTreeLeaf + localRoot common.Hash + localRootErr error + rollupProof treetypes.Proof + rollupProofErr error +} + +func (f *cfgL1) GetInfoByGlobalExitRoot(common.Hash) (*l1infotreesync.L1InfoTreeLeaf, error) { + return f.leaf, nil +} + +func (f *cfgL1) GetLocalExitRoot(context.Context, uint32, common.Hash) (common.Hash, error) { + return f.localRoot, f.localRootErr +} + +func (f *cfgL1) GetRollupExitTreeMerkleProof(context.Context, uint32, common.Hash) (treetypes.Proof, error) { + return f.rollupProof, f.rollupProofErr +} + +// cfgLocalTree is a configurable LocalExitTreeReader for driving claimer error branches. +type cfgLocalTree struct { + depositByHash map[common.Hash]uint32 + metadataByHash map[common.Hash][]byte + proofErr error +} + +func (f *cfgLocalTree) DepositCount(h common.Hash) (uint32, bool) { + dc, ok := f.depositByHash[h] + return dc, ok +} +func (f *cfgLocalTree) Metadata(h common.Hash) ([]byte, bool) { + m, ok := f.metadataByHash[h] + return m, ok +} +func (f *cfgLocalTree) Proof(context.Context, uint32, common.Hash) (treetypes.Proof, error) { + return treetypes.Proof{}, f.proofErr +} + +func cfgClaimer(t *testing.T, l1 L1InfoTreeQuerier, tree LocalExitTreeReader) (*Claimer, common.Address) { + t.Helper() + cert, err := LoadCertificate(writeSampleCert(t)) + require.NoError(t, err) + waitResult := &exitcertificate.StepWaitResult{ + UpdateL1InfoTree: &exitcertificate.L1InfoTreeUpdate{ + MainnetExitRoot: common.HexToHash("0x1111"), + RollupExitRoot: common.HexToHash("0x2222"), + }, + } + return NewClaimer(log.GetDefaultLogger(), cert, tree, l1, 0, waitResult), cert.Leaves[0].DestinationAddress +} + +func TestBuildClaimParamsGetLocalExitRootError(t *testing.T) { + t.Parallel() + boom := errors.New("boom") + l1 := &cfgL1{leaf: &l1infotreesync.L1InfoTreeLeaf{}, localRootErr: boom} + c, dest := cfgClaimer(t, l1, &cfgLocalTree{}) + _, err := c.BuildClaimParams(context.Background(), dest, nil) + require.ErrorIs(t, err, boom) +} + +func TestBuildClaimParamsRollupProofError(t *testing.T) { + t.Parallel() + cert, err := LoadCertificate(writeSampleCert(t)) + require.NoError(t, err) + boom := errors.New("rollup boom") + // localRoot must match the certificate so the settlement check passes, then rollup proof errors. + l1 := &cfgL1{leaf: &l1infotreesync.L1InfoTreeLeaf{}, localRoot: cert.NewLocalExitRoot, rollupProofErr: boom} + c, dest := cfgClaimer(t, l1, &cfgLocalTree{}) + _, err = c.BuildClaimParams(context.Background(), dest, nil) + require.ErrorIs(t, err, boom) +} + +func TestBuildClaimParamsDepositCountMissing(t *testing.T) { + t.Parallel() + cert, err := LoadCertificate(writeSampleCert(t)) + require.NoError(t, err) + l1 := &cfgL1{leaf: &l1infotreesync.L1InfoTreeLeaf{}, localRoot: cert.NewLocalExitRoot} + // Empty local tree → deposit count for the matching leaf is missing. + c, dest := cfgClaimer(t, l1, &cfgLocalTree{}) + _, err = c.BuildClaimParams(context.Background(), dest, nil) + require.ErrorContains(t, err, "not found in local exit tree") +} + +func TestBuildClaimParamsMetadataMissing(t *testing.T) { + t.Parallel() + cert, err := LoadCertificate(writeSampleCert(t)) + require.NoError(t, err) + l1 := &cfgL1{leaf: &l1infotreesync.L1InfoTreeLeaf{}, localRoot: cert.NewLocalExitRoot} + // Deposit count present but metadata absent. + tree := &cfgLocalTree{depositByHash: map[common.Hash]uint32{cert.Leaves[0].Hash(): 5}} + c, dest := cfgClaimer(t, l1, tree) + _, err = c.BuildClaimParams(context.Background(), dest, nil) + require.ErrorContains(t, err, "no metadata in local exit tree") +} + +func TestBuildClaimParamsProofError(t *testing.T) { + t.Parallel() + cert, err := LoadCertificate(writeSampleCert(t)) + require.NoError(t, err) + boom := errors.New("proof boom") + l1 := &cfgL1{leaf: &l1infotreesync.L1InfoTreeLeaf{}, localRoot: cert.NewLocalExitRoot} + tree := &cfgLocalTree{ + depositByHash: map[common.Hash]uint32{cert.Leaves[0].Hash(): 5}, + metadataByHash: map[common.Hash][]byte{cert.Leaves[0].Hash(): {0x01}}, + proofErr: boom, + } + c, dest := cfgClaimer(t, l1, tree) + _, err = c.BuildClaimParams(context.Background(), dest, nil) + require.ErrorIs(t, err, boom) +} diff --git a/tools/exit_certificate_claimer/service/claimer_test.go b/tools/exit_certificate_claimer/service/claimer_test.go new file mode 100644 index 000000000..c1ed3a59a --- /dev/null +++ b/tools/exit_certificate_claimer/service/claimer_test.go @@ -0,0 +1,138 @@ +package claimer + +import ( + "context" + "testing" + + "github.com/agglayer/aggkit/l1infotreesync" + "github.com/agglayer/aggkit/log" + exitcertificate "github.com/agglayer/aggkit/tools/exit_certificate" + treetypes "github.com/agglayer/aggkit/tree/types" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +// fakeLocalTree is an in-memory LocalExitTreeReader: it maps the certificate leaves to deposit +// counts by their position in the certificate. +type fakeLocalTree struct { + depositByHash map[common.Hash]uint32 + metadataByHash map[common.Hash][]byte + proof treetypes.Proof +} + +func (f *fakeLocalTree) DepositCount(leafHash common.Hash) (uint32, bool) { + dc, ok := f.depositByHash[leafHash] + return dc, ok +} + +func (f *fakeLocalTree) Metadata(leafHash common.Hash) ([]byte, bool) { + m, ok := f.metadataByHash[leafHash] + return m, ok +} + +func (f *fakeLocalTree) Proof(_ context.Context, _ uint32, _ common.Hash) (treetypes.Proof, error) { + return f.proof, nil +} + +// fakeL1 is a fake L1InfoTreeQuerier returning a fixed leaf and proof. +type fakeL1 struct { + leaf *l1infotreesync.L1InfoTreeLeaf + localRoot common.Hash + rollupProof treetypes.Proof +} + +func (f *fakeL1) GetInfoByGlobalExitRoot(_ common.Hash) (*l1infotreesync.L1InfoTreeLeaf, error) { + return f.leaf, nil +} + +func (f *fakeL1) GetLocalExitRoot(_ context.Context, _ uint32, _ common.Hash) (common.Hash, error) { + return f.localRoot, nil +} + +func (f *fakeL1) GetRollupExitTreeMerkleProof( + _ context.Context, _ uint32, _ common.Hash, +) (treetypes.Proof, error) { + return f.rollupProof, nil +} + +func buildTestClaimer(t *testing.T, settledRoot common.Hash) (*Claimer, common.Address) { + t.Helper() + + cert, err := LoadCertificate(writeSampleCert(t)) + require.NoError(t, err) + + depositByHash := make(map[common.Hash]uint32, len(cert.Leaves)) + metadataByHash := make(map[common.Hash][]byte, len(cert.Leaves)) + for i, leaf := range cert.Leaves { + depositByHash[leaf.Hash()] = uint32(i + 5) // offset to prove the count is carried through + metadataByHash[leaf.Hash()] = []byte{0xab, 0xcd} + } + + localTree := &fakeLocalTree{depositByHash: depositByHash, metadataByHash: metadataByHash} + l1 := &fakeL1{ + leaf: &l1infotreesync.L1InfoTreeLeaf{ + L1InfoTreeIndex: 9, + MainnetExitRoot: common.HexToHash("0x1111"), + RollupExitRoot: common.HexToHash("0x2222"), + }, + localRoot: settledRoot, + } + + waitResult := &exitcertificate.StepWaitResult{ + UpdateL1InfoTree: &exitcertificate.L1InfoTreeUpdate{ + MainnetExitRoot: common.HexToHash("0x1111"), + RollupExitRoot: common.HexToHash("0x2222"), + }, + } + + claimer := NewClaimer(log.GetDefaultLogger(), cert, localTree, l1, 0, waitResult) + return claimer, cert.Leaves[0].DestinationAddress +} + +func TestListBridges(t *testing.T) { + t.Parallel() + + cert, err := LoadCertificate(writeSampleCert(t)) + require.NoError(t, err) + destAddr := cert.Leaves[0].DestinationAddress + + claimer, _ := buildTestClaimer(t, cert.NewLocalExitRoot) + bridges, err := claimer.ListBridges(destAddr) + require.NoError(t, err) + require.Len(t, bridges, 1) + require.Equal(t, destAddr.Hex(), bridges[0].DestinationAddress) + require.Equal(t, uint32(5), bridges[0].DepositCount) +} + +func TestBuildClaimParams(t *testing.T) { + t.Parallel() + + cert, err := LoadCertificate(writeSampleCert(t)) + require.NoError(t, err) + destAddr := cert.Leaves[0].DestinationAddress + + claimer, _ := buildTestClaimer(t, cert.NewLocalExitRoot) + claims, err := claimer.BuildClaimParams(context.Background(), destAddr, nil) + require.NoError(t, err) + require.Len(t, claims, 1) + + got := claims[0] + require.Equal(t, uint32(1), claimer.NetworkID()) // defaulted from certificate + require.Equal(t, uint32(5), got.DepositCount) + require.Equal(t, uint32(9), got.L1InfoTreeIndex) + require.Equal(t, common.HexToHash("0x1111").Hex(), got.MainnetExitRoot) + require.Equal(t, common.HexToHash("0x2222").Hex(), got.RollupExitRoot) + require.Equal(t, "20000005400000000", got.Amount) + // Raw on-chain metadata from the local exit tree, not the certificate's metadata hash. + require.Equal(t, "0xabcd", got.Metadata) + // globalIndex for a rollup (networkID=1 → rollupIndex 0) with deposit count 5. + require.Equal(t, "5", got.GlobalIndex) +} + +func TestBuildClaimParamsNotSettled(t *testing.T) { + t.Parallel() + + claimer, destAddr := buildTestClaimer(t, common.HexToHash("0xdeadbeef")) + _, err := claimer.BuildClaimParams(context.Background(), destAddr, nil) + require.ErrorIs(t, err, ErrLocalExitRootNotSettled) +} diff --git a/tools/exit_certificate_claimer/service/cmd/main.go b/tools/exit_certificate_claimer/service/cmd/main.go new file mode 100644 index 000000000..1e6eab1bc --- /dev/null +++ b/tools/exit_certificate_claimer/service/cmd/main.go @@ -0,0 +1,60 @@ +package main + +import ( + "fmt" + "os" + + aggkit "github.com/agglayer/aggkit" + claimer "github.com/agglayer/aggkit/tools/exit_certificate_claimer/service" + "github.com/urfave/cli/v2" +) + +func main() { + app := cli.NewApp() + app.Name = "exit-certificate-claimer" + app.Usage = "Serve claimAsset parameters for the bridge exits of a settled exit certificate" + app.Version = aggkit.Version + app.Description = `Backend HTTP service for claiming the bridge exits produced by the exit_certificate tool. + +Given a destination address it returns: + - the available bridge exits for that address (GET /claimer/v1/bridges) + - the full set of AgglayerBridge.claimAsset parameters per exit (GET /claimer/v1/claim-params) + +Data sources: + - exit-certificate-signed.json (the certificate bridge exits) + - step-g-l2bridgesyncerlite.sqlite (the L2 local exit tree, for local merkle proofs) + - an l1infotreesync SQLite database (mainnet/rollup exit roots + rollup proof), opened + read-only or kept in sync from L1 when l1Sync.enabled.` + app.Flags = []cli.Flag{ + &cli.StringFlag{ + Name: "config", + Aliases: []string{"c"}, + Usage: "Path to the claimer config file (JSON or TOML; format selected by .json/.toml extension)", + Value: "config.toml", + }, + &cli.StringFlag{ + Name: "exit-certificate-config", + Aliases: []string{"e"}, + Usage: "Path to an exit_certificate config file to derive the claimer config from " + + "(mutually exclusive with --config)", + }, + &cli.StringFlag{ + Name: "address", + Usage: "HTTP server bind host/IP, without port (overrides the config)", + }, + &cli.IntFlag{ + Name: "port", + Usage: "HTTP server bind port (overrides the config)", + }, + &cli.BoolFlag{ + Name: "verbose", + Usage: "Enable debug logging", + }, + } + app.Action = claimer.Run + + if err := app.Run(os.Args); err != nil { + _, _ = fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } +} diff --git a/tools/exit_certificate_claimer/service/config.go b/tools/exit_certificate_claimer/service/config.go new file mode 100644 index 000000000..f48065e77 --- /dev/null +++ b/tools/exit_certificate_claimer/service/config.go @@ -0,0 +1,155 @@ +package claimer + +import ( + "encoding/json" + "fmt" + "net" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/pelletier/go-toml/v2" +) + +const ( + defaultAddress = "0.0.0.0" + defaultPort = 7080 + defaultReadTimeoutSeconds = 30 + defaultWriteTimeoutSeconds = 30 +) + +// Config configures the exit certificate claimer backend. +type Config struct { + // Address is the HTTP server bind host/IP (without port). + Address string `json:"address"` + // Port is the HTTP server bind port. + Port int `json:"port"` + // ReadTimeoutSeconds / WriteTimeoutSeconds bound HTTP request handling. + ReadTimeoutSeconds int `json:"readTimeoutSeconds"` + WriteTimeoutSeconds int `json:"writeTimeoutSeconds"` + + // SignedCertificatePath is the path to exit-certificate-signed.json produced by exit_certificate. + SignedCertificatePath string `json:"signedCertificatePath"` + // LocalExitTreeDBPath is the path to step-g-l2bridgesyncerlite.sqlite produced by exit_certificate. + LocalExitTreeDBPath string `json:"localExitTreeDBPath"` + // L1InfoTreeDBPath is the path to the l1infotreesync SQLite database. + L1InfoTreeDBPath string `json:"l1InfoTreeDBPath"` + // StepWaitResultPath is the path to step-wait-result.json produced by the exit_certificate WAIT + // step. It records the certificate's L1 settlement (the VerifyBatchesTrustedAggregator event and + // the accompanying L1 Info Tree update), identifying the L1 info tree leaf it settled at. + StepWaitResultPath string `json:"stepWaitResultPath"` + + // NetworkID is the source network of the exits. Defaults to the certificate's network_id when 0. + NetworkID uint32 `json:"networkId"` + + // L1Sync controls optional background synchronization of the L1 Info Tree from L1. + L1Sync L1SyncConfig `json:"l1Sync"` +} + +// L1SyncConfig controls the optional L1 Info Tree synchronization. When Enabled is false the +// L1InfoTreeDBPath database is opened read-only. +type L1SyncConfig struct { + Enabled bool `json:"enabled"` + RPCURL string `json:"rpcUrl"` + GlobalExitRootAddr string `json:"globalExitRootAddr"` + RollupManagerAddr string `json:"rollupManagerAddr"` + InitialBlock uint64 `json:"initialBlock"` + SyncBlockChunkSize uint64 `json:"syncBlockChunkSize"` + BlockFinality string `json:"blockFinality"` +} + +// LoadConfig reads and validates the config file. JSON and TOML are both accepted; the format is +// selected by file extension (.toml → TOML, anything else → JSON). Relative file paths are +// resolved against the directory containing the config file. +func LoadConfig(path string) (*Config, error) { + raw, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("reading config %q: %w", path, err) + } + + if strings.EqualFold(filepath.Ext(path), ".toml") { + raw, err = tomlToJSON(raw) + if err != nil { + return nil, fmt.Errorf("parsing TOML config %q: %w", path, err) + } + } + + var cfg Config + if err := json.Unmarshal(raw, &cfg); err != nil { + return nil, fmt.Errorf("parsing config %q: %w", path, err) + } + + cfg.applyDefaults() + cfg.resolvePaths(filepath.Dir(path)) + + if err := cfg.validate(); err != nil { + return nil, err + } + return &cfg, nil +} + +// ListenAddress returns the host:port the HTTP server binds to. +func (c *Config) ListenAddress() string { + return net.JoinHostPort(c.Address, strconv.Itoa(c.Port)) +} + +func (c *Config) applyDefaults() { + if c.Address == "" { + c.Address = defaultAddress + } + if c.Port == 0 { + c.Port = defaultPort + } + if c.ReadTimeoutSeconds == 0 { + c.ReadTimeoutSeconds = defaultReadTimeoutSeconds + } + if c.WriteTimeoutSeconds == 0 { + c.WriteTimeoutSeconds = defaultWriteTimeoutSeconds + } +} + +func (c *Config) resolvePaths(baseDir string) { + c.SignedCertificatePath = resolvePath(baseDir, c.SignedCertificatePath) + c.LocalExitTreeDBPath = resolvePath(baseDir, c.LocalExitTreeDBPath) + c.L1InfoTreeDBPath = resolvePath(baseDir, c.L1InfoTreeDBPath) + c.StepWaitResultPath = resolvePath(baseDir, c.StepWaitResultPath) +} + +func (c *Config) validate() error { + if c.SignedCertificatePath == "" { + return fmt.Errorf("signedCertificatePath is required") + } + if c.LocalExitTreeDBPath == "" { + return fmt.Errorf("localExitTreeDBPath is required") + } + if c.L1InfoTreeDBPath == "" { + return fmt.Errorf("l1InfoTreeDBPath is required") + } + if c.StepWaitResultPath == "" { + return fmt.Errorf("stepWaitResultPath is required") + } + if c.L1Sync.Enabled { + if c.L1Sync.RPCURL == "" { + return fmt.Errorf("l1Sync.rpcUrl is required when l1Sync.enabled is true") + } + } + return nil +} + +// resolvePath makes a relative path absolute against baseDir; empty and absolute paths are unchanged. +func resolvePath(baseDir, p string) string { + if p == "" || filepath.IsAbs(p) { + return p + } + return filepath.Join(baseDir, p) +} + +// tomlToJSON normalizes a TOML document into JSON so both formats share one parsing path. +func tomlToJSON(tomlRaw []byte) ([]byte, error) { + var m map[string]any + if err := toml.Unmarshal(tomlRaw, &m); err != nil { + return nil, err + } + return json.Marshal(m) +} diff --git a/tools/exit_certificate_claimer/service/config.toml.example b/tools/exit_certificate_claimer/service/config.toml.example new file mode 100644 index 000000000..6524cf0c9 --- /dev/null +++ b/tools/exit_certificate_claimer/service/config.toml.example @@ -0,0 +1,31 @@ +# exit_certificate_claimer backend configuration (TOML). +# Relative paths are resolved against the directory containing this file. + +# HTTP server bind host/IP (without port) and port. +address = "0.0.0.0" +port = 8080 +readTimeoutSeconds = 30 +writeTimeoutSeconds = 30 + +# Outputs of the exit_certificate tool. +signedCertificatePath = "../../exit_certificate/output/exit-certificate-signed.json" +localExitTreeDBPath = "../../exit_certificate/output/step-g-l2bridgesyncerlite.sqlite" +# WAIT step result: records the certificate's L1 settlement (the L1 info tree leaf it settled at). +stepWaitResultPath = "../../exit_certificate/output/step-wait-result.json" + +# L1 Info Tree SQLite database (queried for mainnet/rollup exit roots and the rollup proof). +l1InfoTreeDBPath = "./l1infotree.sqlite" + +# Source network of the exits. Leave 0 to default to the certificate's network_id. +networkId = 0 + +# Optional background synchronization of the L1 Info Tree from L1. +# When enabled = false the l1InfoTreeDBPath database is opened read-only. +[l1Sync] +enabled = false +rpcUrl = "http://localhost:8545" +globalExitRootAddr = "0x0000000000000000000000000000000000000000" +rollupManagerAddr = "0x0000000000000000000000000000000000000000" +initialBlock = 0 +syncBlockChunkSize = 5000 +blockFinality = "FinalizedBlock" diff --git a/tools/exit_certificate_claimer/service/config_test.go b/tools/exit_certificate_claimer/service/config_test.go new file mode 100644 index 000000000..eb3b87f48 --- /dev/null +++ b/tools/exit_certificate_claimer/service/config_test.go @@ -0,0 +1,180 @@ +package claimer + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +const sampleJSONConfig = `{ + "address": "127.0.0.1", + "port": 9090, + "signedCertificatePath": "exit-certificate-signed.json", + "localExitTreeDBPath": "step-g-l2bridgesyncerlite.sqlite", + "l1InfoTreeDBPath": "L1InfoTreeSync.sqlite", + "stepWaitResultPath": "step-wait-result.json", + "networkId": 2, + "l1Sync": { + "enabled": true, + "rpcUrl": "http://localhost:8545" + } +}` + +func writeConfig(t *testing.T, name, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), name) + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) + return path +} + +func TestLoadConfigJSON(t *testing.T) { + t.Parallel() + + path := writeConfig(t, "config.json", sampleJSONConfig) + cfg, err := LoadConfig(path) + require.NoError(t, err) + + require.Equal(t, "127.0.0.1", cfg.Address) + require.Equal(t, 9090, cfg.Port) + require.Equal(t, uint32(2), cfg.NetworkID) + require.True(t, cfg.L1Sync.Enabled) + require.Equal(t, "http://localhost:8545", cfg.L1Sync.RPCURL) + + // Relative paths are resolved against the config's directory. + baseDir := filepath.Dir(path) + require.Equal(t, filepath.Join(baseDir, "exit-certificate-signed.json"), cfg.SignedCertificatePath) + require.Equal(t, filepath.Join(baseDir, "step-wait-result.json"), cfg.StepWaitResultPath) + + // Defaults applied for the unspecified timeouts. + require.Equal(t, defaultReadTimeoutSeconds, cfg.ReadTimeoutSeconds) + require.Equal(t, defaultWriteTimeoutSeconds, cfg.WriteTimeoutSeconds) +} + +func TestLoadConfigTOML(t *testing.T) { + t.Parallel() + + const tomlConfig = ` +port = 8081 +signedCertificatePath = "/abs/exit-certificate-signed.json" +localExitTreeDBPath = "/abs/local.sqlite" +l1InfoTreeDBPath = "/abs/l1.sqlite" +stepWaitResultPath = "/abs/step-wait-result.json" + +[l1Sync] +enabled = false +` + path := writeConfig(t, "config.toml", tomlConfig) + cfg, err := LoadConfig(path) + require.NoError(t, err) + + require.Equal(t, 8081, cfg.Port) + require.Equal(t, defaultAddress, cfg.Address) // default applied + require.False(t, cfg.L1Sync.Enabled) + // Absolute paths are left untouched. + require.Equal(t, "/abs/exit-certificate-signed.json", cfg.SignedCertificatePath) +} + +func TestLoadConfigDefaults(t *testing.T) { + t.Parallel() + + const minimal = `{ + "signedCertificatePath": "/c.json", + "localExitTreeDBPath": "/l.sqlite", + "l1InfoTreeDBPath": "/i.sqlite", + "stepWaitResultPath": "/w.json" +}` + path := writeConfig(t, "config.json", minimal) + cfg, err := LoadConfig(path) + require.NoError(t, err) + + require.Equal(t, defaultAddress, cfg.Address) + require.Equal(t, defaultPort, cfg.Port) + require.Equal(t, defaultReadTimeoutSeconds, cfg.ReadTimeoutSeconds) + require.Equal(t, defaultWriteTimeoutSeconds, cfg.WriteTimeoutSeconds) +} + +func TestLoadConfigErrors(t *testing.T) { + t.Parallel() + + _, err := LoadConfig(filepath.Join(t.TempDir(), "missing.json")) + require.Error(t, err) + + _, err = LoadConfig(writeConfig(t, "bad.json", `{not json`)) + require.ErrorContains(t, err, "parsing config") + + _, err = LoadConfig(writeConfig(t, "bad.toml", "= invalid toml")) + require.ErrorContains(t, err, "parsing TOML config") + + // Parses fine but fails validation (required path missing). + const incomplete = `{"localExitTreeDBPath": "/l.sqlite"}` + _, err = LoadConfig(writeConfig(t, "incomplete.json", incomplete)) + require.ErrorContains(t, err, "signedCertificatePath is required") +} + +func TestConfigValidate(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + mutate func(*Config) + wantErr string + }{ + { + name: "missing signed certificate", + mutate: func(c *Config) { c.SignedCertificatePath = "" }, + wantErr: "signedCertificatePath is required", + }, + { + name: "missing local exit tree", + mutate: func(c *Config) { c.LocalExitTreeDBPath = "" }, + wantErr: "localExitTreeDBPath is required", + }, + { + name: "missing l1 info tree", + mutate: func(c *Config) { c.L1InfoTreeDBPath = "" }, + wantErr: "l1InfoTreeDBPath is required", + }, + { + name: "missing wait result", + mutate: func(c *Config) { c.StepWaitResultPath = "" }, + wantErr: "stepWaitResultPath is required", + }, + { + name: "l1 sync enabled without rpc url", + mutate: func(c *Config) { + c.L1Sync.Enabled = true + c.L1Sync.RPCURL = "" + }, + wantErr: "l1Sync.rpcUrl is required", + }, + } + + valid := func() *Config { + return &Config{ + SignedCertificatePath: "/c.json", + LocalExitTreeDBPath: "/l.sqlite", + L1InfoTreeDBPath: "/i.sqlite", + StepWaitResultPath: "/w.json", + } + } + + require.NoError(t, valid().validate()) + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + cfg := valid() + tc.mutate(cfg) + require.ErrorContains(t, cfg.validate(), tc.wantErr) + }) + } +} + +func TestListenAddress(t *testing.T) { + t.Parallel() + + cfg := &Config{Address: "127.0.0.1", Port: 7080} + require.Equal(t, "127.0.0.1:7080", cfg.ListenAddress()) +} diff --git a/tools/exit_certificate_claimer/service/derive.go b/tools/exit_certificate_claimer/service/derive.go new file mode 100644 index 000000000..aaed443b5 --- /dev/null +++ b/tools/exit_certificate_claimer/service/derive.go @@ -0,0 +1,128 @@ +package claimer + +import ( + "context" + "fmt" + "path/filepath" + + "github.com/0xPolygon/cdk-contracts-tooling/contracts/aggchain-multisig/aggchainbase" + "github.com/agglayer/aggkit/log" + exitcertificate "github.com/agglayer/aggkit/tools/exit_certificate" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/urfave/cli/v2" +) + +// derivedBlockFinality is the L1 finality the derived L1 Info Tree sync runs at. It is fixed (not +// taken from the exit_certificate config, whose targetBlock is an L2 reference) because the L1 Info +// Tree must only follow finalized L1 state. +const derivedBlockFinality = "FinalizedBlock" + +// loadOrDeriveConfig selects the config source from the CLI flags: --exit-certificate-config +// derives the claimer config from an exit_certificate config file, otherwise --config loads a native +// claimer config. The two are mutually exclusive. +func loadOrDeriveConfig(ctx context.Context, c *cli.Context, logger *log.Logger) (*Config, error) { + cfg, err := selectConfig(ctx, c, logger) + if err != nil { + return nil, err + } + // CLI overrides apply to both the native and derived config. + if c.IsSet("address") { + cfg.Address = c.String("address") + } + if c.IsSet("port") { + cfg.Port = c.Int("port") + } + return cfg, nil +} + +// selectConfig loads the native claimer config, or derives one from an exit_certificate config when +// --exit-certificate-config is given. The two config sources are mutually exclusive. +func selectConfig(ctx context.Context, c *cli.Context, logger *log.Logger) (*Config, error) { + ecConfigPath := c.String("exit-certificate-config") + if ecConfigPath == "" { + return LoadConfig(c.String("config")) + } + if c.IsSet("config") { + return nil, fmt.Errorf("--config and --exit-certificate-config are mutually exclusive") + } + + logger.Infof("deriving claimer config from exit_certificate config %q", ecConfigPath) + ecCfg, err := exitcertificate.LoadConfig(ecConfigPath) + if err != nil { + return nil, fmt.Errorf("loading exit_certificate config %q: %w", ecConfigPath, err) + } + return DeriveFromExitCertificate(ctx, ecCfg) +} + +// DeriveFromExitCertificate builds a claimer Config from the exit_certificate tool's Config so both +// tools can share a single source of truth. File paths point inside the exit_certificate output +// directory, the L1 sync parameters reuse the L1 RPC/contracts and tuning, and L1 sync is enabled +// so the claimer keeps the L1 Info Tree DB up to date on its own. +// +// The RollupManager address is not present in the exit_certificate config, so it is always resolved +// on-chain by calling RollupManager() on the aggchainbase contract at SovereignRollupAddr; this +// requires L1RpcUrl to be reachable. +func DeriveFromExitCertificate(ctx context.Context, ec *exitcertificate.Config) (*Config, error) { + outputDir := ec.Options.OutputDir + + rollupManager, err := resolveRollupManager(ctx, ec) + if err != nil { + return nil, err + } + + cfg := &Config{ + Address: defaultAddress, + Port: defaultPort, + ReadTimeoutSeconds: defaultReadTimeoutSeconds, + WriteTimeoutSeconds: defaultWriteTimeoutSeconds, + SignedCertificatePath: filepath.Join(outputDir, "exit-certificate-signed.json"), + LocalExitTreeDBPath: filepath.Join(outputDir, "step-g-l2bridgesyncerlite.sqlite"), + L1InfoTreeDBPath: filepath.Join(outputDir, "L1InfoTreeSync.sqlite"), + StepWaitResultPath: filepath.Join(outputDir, "step-wait-result.json"), + NetworkID: ec.L2NetworkID, + L1Sync: L1SyncConfig{ + Enabled: true, + RPCURL: ec.L1RPCURL, + GlobalExitRootAddr: ec.L1GlobalExitRootAddress.Hex(), + RollupManagerAddr: rollupManager, + InitialBlock: ec.Options.L1StartBlock, + SyncBlockChunkSize: uint64(ec.Options.BlockRange), + BlockFinality: derivedBlockFinality, + }, + } + + if err := cfg.validate(); err != nil { + return nil, fmt.Errorf("derived config is invalid: %w", err) + } + return cfg, nil +} + +// resolveRollupManager dials L1 and reads RollupManager() from the aggchainbase contract at +// SovereignRollupAddr, returning its hex address. +func resolveRollupManager(ctx context.Context, ec *exitcertificate.Config) (string, error) { + if ec.L1RPCURL == "" { + return "", fmt.Errorf("cannot resolve RollupManager: l1RpcUrl is not set") + } + if (ec.SovereignRollupAddr == common.Address{}) { + return "", fmt.Errorf("cannot resolve RollupManager: sovereignRollupAddr is not set") + } + + l1Client, err := ethclient.DialContext(ctx, ec.L1RPCURL) + if err != nil { + return "", fmt.Errorf("dialing L1 RPC %q: %w", ec.L1RPCURL, err) + } + defer l1Client.Close() + + caller, err := aggchainbase.NewAggchainbaseCaller(ec.SovereignRollupAddr, l1Client) + if err != nil { + return "", fmt.Errorf("creating aggchainbase caller (addr=%s): %w", ec.SovereignRollupAddr.Hex(), err) + } + + rollupManager, err := caller.RollupManager(&bind.CallOpts{Context: ctx}) + if err != nil { + return "", fmt.Errorf("querying RollupManager() on %s: %w", ec.SovereignRollupAddr.Hex(), err) + } + return rollupManager.Hex(), nil +} diff --git a/tools/exit_certificate_claimer/service/derive_select_test.go b/tools/exit_certificate_claimer/service/derive_select_test.go new file mode 100644 index 000000000..92bbe5d29 --- /dev/null +++ b/tools/exit_certificate_claimer/service/derive_select_test.go @@ -0,0 +1,166 @@ +package claimer + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/agglayer/aggkit/log" + exitcertificate "github.com/agglayer/aggkit/tools/exit_certificate" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" + "github.com/urfave/cli/v2" +) + +// newCLIContext builds a urfave/cli context with the flags loadOrDeriveConfig/selectConfig read, +// parsed from args (e.g. []string{"--config", path}). Flags present in args report IsSet()==true. +func newCLIContext(t *testing.T, args []string) *cli.Context { + t.Helper() + fs := flag.NewFlagSet("test", flag.ContinueOnError) + fs.String("config", "", "") + fs.String("exit-certificate-config", "", "") + fs.String("address", "", "") + fs.Int("port", 0, "") + require.NoError(t, fs.Parse(args)) + return cli.NewContext(nil, fs, nil) +} + +func writeNativeClaimerConfig(t *testing.T) string { + t.Helper() + const cfg = `{ + "signedCertificatePath": "/c.json", + "localExitTreeDBPath": "/l.sqlite", + "l1InfoTreeDBPath": "/i.sqlite", + "stepWaitResultPath": "/w.json" +}` + path := filepath.Join(t.TempDir(), "config.json") + require.NoError(t, os.WriteFile(path, []byte(cfg), 0o600)) + return path +} + +func writeExitCertificateConfig(t *testing.T) string { + t.Helper() + const cfg = `{ + "l2RpcUrl": "http://localhost:8545", + "l2BridgeAddress": "0x1111111111111111111111111111111111111111", + "exitAddress": "0x2222222222222222222222222222222222222222", + "targetBlock": "100", + "options": { + "useAgglayerAdminToStepFCheck": false + } +}` + path := filepath.Join(t.TempDir(), "exit-certificate.json") + require.NoError(t, os.WriteFile(path, []byte(cfg), 0o600)) + return path +} + +func TestSelectConfigNative(t *testing.T) { + t.Parallel() + c := newCLIContext(t, []string{"--config", writeNativeClaimerConfig(t)}) + cfg, err := selectConfig(context.Background(), c, log.GetDefaultLogger()) + require.NoError(t, err) + require.Equal(t, defaultAddress, cfg.Address) +} + +func TestSelectConfigMutuallyExclusive(t *testing.T) { + t.Parallel() + c := newCLIContext(t, []string{ + "--config", writeNativeClaimerConfig(t), + "--exit-certificate-config", writeExitCertificateConfig(t), + }) + _, err := selectConfig(context.Background(), c, log.GetDefaultLogger()) + require.ErrorContains(t, err, "mutually exclusive") +} + +func TestSelectConfigDeriveBadPath(t *testing.T) { + t.Parallel() + c := newCLIContext(t, []string{"--exit-certificate-config", filepath.Join(t.TempDir(), "missing.json")}) + _, err := selectConfig(context.Background(), c, log.GetDefaultLogger()) + require.ErrorContains(t, err, "loading exit_certificate config") +} + +func TestSelectConfigDeriveResolveRollupManagerFails(t *testing.T) { + t.Parallel() + // A valid exit_certificate config with no l1RpcUrl: deriving fails resolving RollupManager. + c := newCLIContext(t, []string{"--exit-certificate-config", writeExitCertificateConfig(t)}) + _, err := selectConfig(context.Background(), c, log.GetDefaultLogger()) + require.ErrorContains(t, err, "l1RpcUrl is not set") +} + +func TestLoadOrDeriveConfigAppliesCLIOverrides(t *testing.T) { + t.Parallel() + c := newCLIContext(t, []string{ + "--config", writeNativeClaimerConfig(t), + "--address", "0.0.0.0", + "--port", "12345", + }) + cfg, err := loadOrDeriveConfig(context.Background(), c, log.GetDefaultLogger()) + require.NoError(t, err) + require.Equal(t, "0.0.0.0", cfg.Address) + require.Equal(t, 12345, cfg.Port) +} + +func TestLoadOrDeriveConfigPropagatesError(t *testing.T) { + t.Parallel() + // Neither flag set: LoadConfig("") fails and the error propagates. + c := newCLIContext(t, nil) + _, err := loadOrDeriveConfig(context.Background(), c, log.GetDefaultLogger()) + require.Error(t, err) +} + +func TestRunConfigError(t *testing.T) { + // Run with no config flags set fails fast at config loading (before any data source is opened). + c := newCLIContext(t, nil) + require.Error(t, Run(c)) +} + +// newRollupManagerRPCStub serves the single eth_call RollupManager() makes, returning rollupManager +// left-padded to a 32-byte ABI word. ethclient dials it lazily, so no chainId handshake is needed. +func newRollupManagerRPCStub(t *testing.T, rollupManager common.Address) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req map[string]any + require.NoError(t, json.NewDecoder(r.Body).Decode(&req)) + padded := make([]byte, 32) + copy(padded[12:], rollupManager.Bytes()) + resp := map[string]any{ + "jsonrpc": "2.0", + "id": req["id"], + "result": "0x" + common.Bytes2Hex(padded), + } + w.Header().Set("Content-Type", "application/json") + require.NoError(t, json.NewEncoder(w).Encode(resp)) + })) + t.Cleanup(srv.Close) + return srv +} + +func TestDeriveFromExitCertificateSuccess(t *testing.T) { + t.Parallel() + rollupManager := common.HexToAddress("0x9999999999999999999999999999999999999999") + srv := newRollupManagerRPCStub(t, rollupManager) + + ec := &exitcertificate.Config{ + L1RPCURL: srv.URL, + SovereignRollupAddr: common.HexToAddress("0x3333333333333333333333333333333333333333"), + L2NetworkID: 2, + L1GlobalExitRootAddress: common.HexToAddress("0x4444444444444444444444444444444444444444"), + } + ec.Options.OutputDir = t.TempDir() + ec.Options.L1StartBlock = 10 + ec.Options.BlockRange = 5000 + + cfg, err := DeriveFromExitCertificate(context.Background(), ec) + require.NoError(t, err) + require.True(t, cfg.L1Sync.Enabled) + require.Equal(t, srv.URL, cfg.L1Sync.RPCURL) + require.Equal(t, rollupManager.Hex(), cfg.L1Sync.RollupManagerAddr) + require.Equal(t, uint32(2), cfg.NetworkID) + require.Equal(t, fmt.Sprintf("%d", ec.Options.L1StartBlock), fmt.Sprintf("%d", cfg.L1Sync.InitialBlock)) +} diff --git a/tools/exit_certificate_claimer/service/derive_test.go b/tools/exit_certificate_claimer/service/derive_test.go new file mode 100644 index 000000000..b6b86d9d0 --- /dev/null +++ b/tools/exit_certificate_claimer/service/derive_test.go @@ -0,0 +1,36 @@ +package claimer + +import ( + "context" + "testing" + + exitcertificate "github.com/agglayer/aggkit/tools/exit_certificate" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +func TestResolveRollupManagerMissingL1RPC(t *testing.T) { + t.Parallel() + + _, err := resolveRollupManager(context.Background(), &exitcertificate.Config{}) + require.ErrorContains(t, err, "l1RpcUrl is not set") +} + +func TestResolveRollupManagerMissingSovereignRollup(t *testing.T) { + t.Parallel() + + _, err := resolveRollupManager(context.Background(), &exitcertificate.Config{ + L1RPCURL: "http://localhost:8545", + }) + require.ErrorContains(t, err, "sovereignRollupAddr is not set") +} + +func TestDeriveFromExitCertificateRequiresRollupManager(t *testing.T) { + t.Parallel() + + // Without L1 RPC the on-chain RollupManager resolution fails before a config is produced. + _, err := DeriveFromExitCertificate(context.Background(), &exitcertificate.Config{ + SovereignRollupAddr: common.HexToAddress("0x1234"), + }) + require.ErrorContains(t, err, "l1RpcUrl is not set") +} diff --git a/tools/exit_certificate_claimer/service/l1infotree.go b/tools/exit_certificate_claimer/service/l1infotree.go new file mode 100644 index 000000000..c8ba11fb1 --- /dev/null +++ b/tools/exit_certificate_claimer/service/l1infotree.go @@ -0,0 +1,217 @@ +package claimer + +import ( + "context" + "errors" + "fmt" + "path/filepath" + "time" + + configtypes "github.com/agglayer/aggkit/config/types" + "github.com/agglayer/aggkit/db" + "github.com/agglayer/aggkit/etherman" + ethermanconfig "github.com/agglayer/aggkit/etherman/config" + "github.com/agglayer/aggkit/l1infotreesync" + "github.com/agglayer/aggkit/log" + "github.com/agglayer/aggkit/multidownloader" + treetypes "github.com/agglayer/aggkit/tree/types" + aggkittypes "github.com/agglayer/aggkit/types" + "github.com/ethereum/go-ethereum/common" +) + +// L1 Info Tree syncer timing defaults, mirroring aggkit's [L1InfoTreeSync] config defaults. They +// must be non-zero: a zero WaitForNewBlocksPeriod makes the downloader's time.NewTicker panic. +const ( + defaultWaitForNewBlocksPeriod = 100 * time.Millisecond + defaultRetryAfterErrorPeriod = time.Second + defaultMaxRetryAttemptsAfterError = -1 +) + +// gerSyncPollInterval is how often OpenL1InfoTree polls the DB for the settlement GER while the L1 +// sync catches up to it. +const gerSyncPollInterval = 2 * time.Second + +// L1InfoTreeQuerier is the subset of the l1infotreesync API the claimer needs to assemble the +// rollup-side claimAsset parameters. *l1infotreesync.L1InfoTreeSync satisfies it. +type L1InfoTreeQuerier interface { + GetInfoByGlobalExitRoot(ger common.Hash) (*l1infotreesync.L1InfoTreeLeaf, error) + GetLocalExitRoot(ctx context.Context, networkID uint32, rollupExitRoot common.Hash) (common.Hash, error) + GetRollupExitTreeMerkleProof(ctx context.Context, networkID uint32, root common.Hash) (treetypes.Proof, error) +} + +// gerProber is the minimal surface gerIndexed/waitForGER need to check whether a GER is indexed. +// *l1infotreesync.L1InfoTreeSync satisfies it. +type gerProber interface { + GetInfoByGlobalExitRoot(ger common.Hash) (*l1infotreesync.L1InfoTreeLeaf, error) +} + +// OpenL1InfoTree opens the L1 Info Tree syncer, anchored on the certificate's settlement GER. +// +// It first checks, read-only, whether settlementGER is already indexed in the local DB. If it is, +// the database is already caught up to settlement and no L1 sync is started — the read-only syncer +// is returned regardless of cfg.Enabled. If the GER is not yet indexed it must be synced from L1, +// which requires cfg.Enabled: when sync is disabled this is a hard error; when enabled it dials L1, +// wires a multidownloader-based syncer, and syncs until the settlement GER is indexed — then stops +// the sync (the DB now has everything the claimer needs) and returns the syncer for queries. +func OpenL1InfoTree( + ctx context.Context, cfg L1SyncConfig, dbPath string, settlementGER common.Hash, logger *log.Logger, +) (*l1infotreesync.L1InfoTreeSync, error) { + // Read-only probe for the settlement GER. NewReadOnly only attaches to the SQLite DB, so this is + // cheap and safe whether or not sync is enabled. + readOnly, err := l1infotreesync.NewReadOnly(ctx, dbPath) + if err != nil { + return nil, fmt.Errorf("opening read-only L1 info tree at %q: %w", dbPath, err) + } + indexed, err := gerIndexed(readOnly, settlementGER) + if err != nil { + return nil, err + } + if indexed { + logger.Infof("settlement GER %s already indexed in the L1 info tree; L1 sync not needed", + settlementGER.Hex()) + return readOnly, nil + } + + // The settlement GER is not in the DB yet. It can only be obtained by syncing from L1, which + // requires sync to be enabled. (The read-only handle above stays open — L1InfoTreeSync exposes no + // Close — but it is just an idle WAL reader alongside the syncer opened below.) + if !cfg.Enabled { + return nil, fmt.Errorf( + "settlement GER %s is not in the L1 info tree DB %q and L1 sync is disabled: "+ + "enable l1Sync (enabled=true with rpcUrl/contracts) so the claimer can sync it from L1", + settlementGER.Hex(), dbPath) + } + + logger.Infof("settlement GER %s not yet indexed; starting L1 info tree sync from L1", settlementGER.Hex()) + + finality, err := resolveBlockFinality(cfg.BlockFinality) + if err != nil { + return nil, err + } + + l1Client, err := etherman.NewRPCClient(ctx, logger, ethermanconfig.RPCClientConfig{ + URL: cfg.RPCURL, + Mode: ethermanconfig.RPCModeBasic, + }) + if err != nil { + return nil, fmt.Errorf("dialing L1 RPC %q: %w", cfg.RPCURL, err) + } + + // The multidownloader keeps its own storage and reorg processor next to the L1 Info Tree DB, + // so no separate reorg detector is needed. + mdCfg := multidownloader.NewConfigDefault("l1infotree", filepath.Dir(dbPath)) + mdCfg.BlockFinality = finality + if cfg.SyncBlockChunkSize > 0 { + mdCfg.BlockChunkSize = uint32(cfg.SyncBlockChunkSize) + } + + l1MultiDownloader, err := multidownloader.NewEVMMultidownloader( + logger, mdCfg, "l1", + l1Client, // ethClient + l1Client, // rpcClient + nil, // storage (created inside the multidownloader) + nil, // blockNotifierManager (created inside the multidownloader) + nil, // reorgProcessor (created inside the multidownloader) + ) + if err != nil { + return nil, fmt.Errorf("creating L1 multidownloader: %w", err) + } + + syncer, err := l1infotreesync.NewMultidownloadBased( + ctx, + l1infotreesync.Config{ + DBPath: dbPath, + GlobalExitRootAddr: common.HexToAddress(cfg.GlobalExitRootAddr), + RollupManagerAddr: common.HexToAddress(cfg.RollupManagerAddr), + BlockFinality: finality, + SyncBlockChunkSize: cfg.SyncBlockChunkSize, + InitialBlock: cfg.InitialBlock, + WaitForNewBlocksPeriod: configtypes.Duration{Duration: defaultWaitForNewBlocksPeriod}, + RetryAfterErrorPeriod: configtypes.Duration{Duration: defaultRetryAfterErrorPeriod}, + MaxRetryAttemptsAfterError: defaultMaxRetryAttemptsAfterError, + }, + l1MultiDownloader, + l1infotreesync.FlagNone, + ) + if err != nil { + return nil, fmt.Errorf("creating L1 info tree syncer: %w", err) + } + + // Initialize must run after NewMultidownloadBased has registered the syncer. + if err := l1MultiDownloader.Initialize(ctx); err != nil { + return nil, fmt.Errorf("initializing L1 multidownloader: %w", err) + } + + // Sync only until the settlement GER is indexed: run the syncer under a child context, wait for + // the GER to land in the DB, then cancel it. Cancelling stops the sync goroutines without closing + // the DB, so the returned syncer keeps serving reads from the synced-up-to-settlement state. + syncCtx, cancelSync := context.WithCancel(ctx) + go func() { + if startErr := l1MultiDownloader.Start(syncCtx); startErr != nil && syncCtx.Err() == nil { + logger.Errorf("L1 multidownloader stopped: %v", startErr) + } + }() + go syncer.Start(syncCtx) + + err = waitForGER(syncCtx, syncer, settlementGER, logger) + cancelSync() + if err != nil { + return nil, fmt.Errorf("syncing settlement GER %s from L1: %w", settlementGER.Hex(), err) + } + + logger.Infof("settlement GER %s indexed; stopped L1 sync", settlementGER.Hex()) + return syncer, nil +} + +// resolveBlockFinality maps the configured l1Sync.blockFinality string to a BlockNumberFinality, +// defaulting to LatestBlock when the string is empty. An unparseable value is a hard error. +func resolveBlockFinality(blockFinality string) (aggkittypes.BlockNumberFinality, error) { + if blockFinality == "" { + return aggkittypes.LatestBlock, nil + } + f, err := aggkittypes.NewBlockNumberFinality(blockFinality) + if err != nil { + return aggkittypes.BlockNumberFinality{}, + fmt.Errorf("invalid l1Sync.blockFinality %q: %w", blockFinality, err) + } + return *f, nil +} + +// waitForGER polls the L1 info tree DB until the given GER is indexed, returning nil once it is. +// It returns the context error if ctx is cancelled (e.g. the operator interrupts the process) +// before the GER appears, or any query error from the probe. +func waitForGER( + ctx context.Context, syncer gerProber, ger common.Hash, logger *log.Logger, +) error { + ticker := time.NewTicker(gerSyncPollInterval) + defer ticker.Stop() + for { + indexed, err := gerIndexed(syncer, ger) + if err != nil { + return err + } + if indexed { + return nil + } + logger.Debugf("waiting for settlement GER %s to be indexed by the L1 sync", ger.Hex()) + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + } + } +} + +// gerIndexed reports whether the given Global Exit Root is already present in the L1 info tree DB. +// A missing GER (db.ErrNotFound) is reported as not indexed; any other error is propagated. +func gerIndexed(syncer gerProber, ger common.Hash) (bool, error) { + _, err := syncer.GetInfoByGlobalExitRoot(ger) + switch { + case err == nil: + return true, nil + case errors.Is(err, db.ErrNotFound): + return false, nil + default: + return false, fmt.Errorf("querying L1 info tree for GER %s: %w", ger.Hex(), err) + } +} diff --git a/tools/exit_certificate_claimer/service/l1infotree_test.go b/tools/exit_certificate_claimer/service/l1infotree_test.go new file mode 100644 index 000000000..77bdfd0a8 --- /dev/null +++ b/tools/exit_certificate_claimer/service/l1infotree_test.go @@ -0,0 +1,137 @@ +package claimer + +import ( + "context" + "errors" + "path/filepath" + "testing" + + "github.com/agglayer/aggkit/db" + "github.com/agglayer/aggkit/l1infotreesync" + "github.com/agglayer/aggkit/log" + aggkittypes "github.com/agglayer/aggkit/types" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +// stubGERProber is a configurable gerProber. Each call to GetInfoByGlobalExitRoot pops the next +// canned result from results (the last entry is reused once the slice is exhausted), so tests can +// drive multi-poll behaviour deterministically. +type stubGERProber struct { + calls int + results []proberResult +} + +type proberResult struct { + leaf *l1infotreesync.L1InfoTreeLeaf + err error +} + +func (s *stubGERProber) GetInfoByGlobalExitRoot(common.Hash) (*l1infotreesync.L1InfoTreeLeaf, error) { + r := s.results[min(s.calls, len(s.results)-1)] + s.calls++ + return r.leaf, r.err +} + +func TestResolveBlockFinality(t *testing.T) { + t.Parallel() + + t.Run("empty defaults to latest", func(t *testing.T) { + t.Parallel() + got, err := resolveBlockFinality("") + require.NoError(t, err) + require.Equal(t, aggkittypes.LatestBlock, got) + }) + + t.Run("valid value is parsed", func(t *testing.T) { + t.Parallel() + f, err := aggkittypes.NewBlockNumberFinality("FinalizedBlock") + require.NoError(t, err) + + got, err := resolveBlockFinality("FinalizedBlock") + require.NoError(t, err) + require.Equal(t, *f, got) + }) + + t.Run("invalid value is a hard error", func(t *testing.T) { + t.Parallel() + _, err := resolveBlockFinality("not-a-finality") + require.Error(t, err) + require.Contains(t, err.Error(), "invalid l1Sync.blockFinality") + }) +} + +func TestGERIndexed(t *testing.T) { + t.Parallel() + + t.Run("present GER is indexed", func(t *testing.T) { + t.Parallel() + p := &stubGERProber{results: []proberResult{{leaf: &l1infotreesync.L1InfoTreeLeaf{}}}} + indexed, err := gerIndexed(p, common.HexToHash("0x1")) + require.NoError(t, err) + require.True(t, indexed) + }) + + t.Run("ErrNotFound is reported as not indexed", func(t *testing.T) { + t.Parallel() + p := &stubGERProber{results: []proberResult{{err: db.ErrNotFound}}} + indexed, err := gerIndexed(p, common.HexToHash("0x1")) + require.NoError(t, err) + require.False(t, indexed) + }) + + t.Run("other errors are propagated", func(t *testing.T) { + t.Parallel() + boom := errors.New("boom") + p := &stubGERProber{results: []proberResult{{err: boom}}} + _, err := gerIndexed(p, common.HexToHash("0x1")) + require.ErrorIs(t, err, boom) + }) +} + +func TestWaitForGER(t *testing.T) { + t.Parallel() + + t.Run("returns nil once GER is indexed", func(t *testing.T) { + t.Parallel() + p := &stubGERProber{results: []proberResult{{leaf: &l1infotreesync.L1InfoTreeLeaf{}}}} + err := waitForGER(context.Background(), p, common.HexToHash("0x1"), log.GetDefaultLogger()) + require.NoError(t, err) + require.Equal(t, 1, p.calls) + }) + + t.Run("propagates the probe error", func(t *testing.T) { + t.Parallel() + boom := errors.New("boom") + p := &stubGERProber{results: []proberResult{{err: boom}}} + err := waitForGER(context.Background(), p, common.HexToHash("0x1"), log.GetDefaultLogger()) + require.ErrorIs(t, err, boom) + }) + + t.Run("returns ctx error when cancelled before the GER appears", func(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithCancel(context.Background()) + cancel() // already cancelled: the first poll misses, then the select sees ctx.Done + + p := &stubGERProber{results: []proberResult{{err: db.ErrNotFound}}} + err := waitForGER(ctx, p, common.HexToHash("0x1"), log.GetDefaultLogger()) + require.ErrorIs(t, err, context.Canceled) + }) +} + +func TestOpenL1InfoTreeSyncDisabled(t *testing.T) { + t.Parallel() + + // A fresh read-only DB has no GERs indexed, so with sync disabled OpenL1InfoTree must fail with + // the "enable l1Sync" guidance rather than attempting to dial L1. + dbPath := filepath.Join(t.TempDir(), "l1infotree.sqlite") + _, err := OpenL1InfoTree( + context.Background(), + L1SyncConfig{Enabled: false}, + dbPath, + common.HexToHash("0xdead"), + log.GetDefaultLogger(), + ) + require.Error(t, err) + require.Contains(t, err.Error(), "L1 sync is disabled") +} diff --git a/tools/exit_certificate_claimer/service/localexittree.go b/tools/exit_certificate_claimer/service/localexittree.go new file mode 100644 index 000000000..7cbedd089 --- /dev/null +++ b/tools/exit_certificate_claimer/service/localexittree.go @@ -0,0 +1,100 @@ +package claimer + +import ( + "context" + "database/sql" + "fmt" + + "github.com/agglayer/aggkit/db" + "github.com/agglayer/aggkit/log" + "github.com/agglayer/aggkit/tools/exit_certificate/bridgesyncerlite" + "github.com/agglayer/aggkit/tree" + treetypes "github.com/agglayer/aggkit/tree/types" + "github.com/ethereum/go-ethereum/common" +) + +// LocalExitTree wraps the L2 "lite" bridge sync SQLite database produced by the exit_certificate +// tool (step-g-l2bridgesyncerlite.sqlite). It exposes the deposit count of each leaf (by leaf hash) +// and merkle proofs against the local exit tree built in that database. +type LocalExitTree struct { + db *sql.DB + tree treetypes.ReadTreer + depositCount map[common.Hash]uint32 + // metadata maps a leaf hash to the raw bridge metadata of that exit. The claim needs the raw + // bytes (the bridge contract hashes them itself to rebuild the leaf), whereas the certificate + // only carries the already-hashed metadata. + metadata map[common.Hash][]byte +} + +// OpenLocalExitTree opens the local exit tree database in read-only fashion: it builds the +// leaf-hash → deposit-count index from the bridge table and prepares the append-only tree for +// proof generation. The DB must already contain a fully built tree (the exit_certificate Step G2 +// output), otherwise proofs against NewLocalExitRoot will not resolve. +func OpenLocalExitTree(ctx context.Context, dbPath string, logger *log.Logger) (*LocalExitTree, error) { + // Build the deposit-count index using the lite syncer in DB-only mode (no RPC), which knows + // how to read the bridge table via meddler. + syncer, err := bridgesyncerlite.New(ctx, bridgesyncerlite.Config{DBPath: dbPath}, logger) + if err != nil { + return nil, fmt.Errorf("opening lite bridge syncer at %q: %w", dbPath, err) + } + bridges, err := syncer.GetBridges(ctx) + if err != nil { + _ = syncer.Close() + return nil, fmt.Errorf("reading bridges from %q: %w", dbPath, err) + } + if closeErr := syncer.Close(); closeErr != nil { + return nil, fmt.Errorf("closing lite bridge syncer: %w", closeErr) + } + + index := make(map[common.Hash]uint32, len(bridges)) + metadata := make(map[common.Hash][]byte, len(bridges)) + for i := range bridges { + b := bridges[i] + h := b.Hash() + index[h] = b.DepositCount + metadata[h] = b.Metadata + } + + // Open a dedicated connection for the tree. The lite syncer uses an empty tree prefix. + database, err := db.NewSQLiteDB(dbPath) + if err != nil { + return nil, fmt.Errorf("opening local exit tree DB at %q: %w", dbPath, err) + } + + return &LocalExitTree{ + db: database, + tree: tree.NewAppendOnlyTree(database, ""), + depositCount: index, + metadata: metadata, + }, nil +} + +// DepositCount returns the exit-tree leaf index (deposit count) for a given leaf hash. +func (l *LocalExitTree) DepositCount(leafHash common.Hash) (uint32, bool) { + dc, ok := l.depositCount[leafHash] + return dc, ok +} + +// Metadata returns the raw bridge metadata for a given leaf hash, as recorded on-chain in the +// BridgeEvent. The claim needs these raw bytes: the bridge contract hashes them itself to rebuild +// the exit leaf, so passing the certificate's already-hashed metadata would double-hash and fail +// the SMT proof. +func (l *LocalExitTree) Metadata(leafHash common.Hash) ([]byte, bool) { + m, ok := l.metadata[leafHash] + return m, ok +} + +// Proof returns the merkle proof of the leaf at depositCount against the given local exit root. +func (l *LocalExitTree) Proof( + ctx context.Context, depositCount uint32, localExitRoot common.Hash, +) (treetypes.Proof, error) { + return l.tree.GetProof(ctx, depositCount, localExitRoot) +} + +// Close releases the underlying database connection. +func (l *LocalExitTree) Close() error { + if l.db == nil { + return nil + } + return l.db.Close() +} diff --git a/tools/exit_certificate_claimer/service/localexittree_open_test.go b/tools/exit_certificate_claimer/service/localexittree_open_test.go new file mode 100644 index 000000000..f34fd190c --- /dev/null +++ b/tools/exit_certificate_claimer/service/localexittree_open_test.go @@ -0,0 +1,85 @@ +package claimer + +import ( + "context" + "math/big" + "path/filepath" + "testing" + + "github.com/agglayer/aggkit/log" + "github.com/agglayer/aggkit/tools/exit_certificate/bridgesyncerlite" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +// buildLocalExitTreeDB creates a DB-only lite bridge syncer at dbPath, persists the given bridges and +// builds the exit tree, returning the resulting local exit root. It mirrors what exit_certificate +// Step G2 leaves on disk for the claimer to open. +func buildLocalExitTreeDB(t *testing.T, dbPath string, bridges []bridgesyncerlite.BridgeLeaf) common.Hash { + t.Helper() + ctx := context.Background() + syncer, err := bridgesyncerlite.New(ctx, bridgesyncerlite.Config{DBPath: dbPath}, log.GetDefaultLogger()) + require.NoError(t, err) + require.NoError(t, syncer.StoreBridges(ctx, bridges)) + root, err := syncer.BuildTree(ctx) + require.NoError(t, err) + require.NoError(t, syncer.Close()) + return root +} + +func sampleBridges() []bridgesyncerlite.BridgeLeaf { + return []bridgesyncerlite.BridgeLeaf{ + { + BlockNum: 1, BlockPos: 0, LeafType: 0, OriginNetwork: 0, + OriginAddress: common.Address{}, DestinationNetwork: 1, + DestinationAddress: common.HexToAddress("0xaaaa"), Amount: big.NewInt(100), + Metadata: []byte{0x01, 0x02}, DepositCount: 0, + }, + { + BlockNum: 2, BlockPos: 0, LeafType: 0, OriginNetwork: 0, + OriginAddress: common.HexToAddress("0xbbbb"), DestinationNetwork: 1, + DestinationAddress: common.HexToAddress("0xcccc"), Amount: big.NewInt(200), + Metadata: nil, DepositCount: 1, + }, + } +} + +func TestOpenLocalExitTree(t *testing.T) { + t.Parallel() + ctx := context.Background() + dbPath := filepath.Join(t.TempDir(), "step-g-l2bridgesyncerlite.sqlite") + bridges := sampleBridges() + root := buildLocalExitTreeDB(t, dbPath, bridges) + + lt, err := OpenLocalExitTree(ctx, dbPath, log.GetDefaultLogger()) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, lt.Close()) }) + + // Each persisted bridge is indexed by its leaf hash → deposit count and raw metadata. + for _, b := range bridges { + bb := b + dc, ok := lt.DepositCount(bb.Hash()) + require.True(t, ok) + require.Equal(t, bb.DepositCount, dc) + + meta, ok := lt.Metadata(bb.Hash()) + require.True(t, ok) + require.Equal(t, bb.Metadata, meta) + } + + // A proof for deposit count 0 against the built local exit root resolves. + proof, err := lt.Proof(ctx, 0, root) + require.NoError(t, err) + require.NotEqual(t, common.Hash{}, proof[0]) +} + +func TestOpenLocalExitTreeMissingDB(t *testing.T) { + t.Parallel() + // A path under a non-existent directory cannot be opened/migrated. + _, err := OpenLocalExitTree( + context.Background(), + filepath.Join(t.TempDir(), "nope", "missing.sqlite"), + log.GetDefaultLogger(), + ) + require.Error(t, err) +} diff --git a/tools/exit_certificate_claimer/service/localexittree_test.go b/tools/exit_certificate_claimer/service/localexittree_test.go new file mode 100644 index 000000000..09872113a --- /dev/null +++ b/tools/exit_certificate_claimer/service/localexittree_test.go @@ -0,0 +1,84 @@ +package claimer + +import ( + "context" + "testing" + + dbtypes "github.com/agglayer/aggkit/db/types" + treetypes "github.com/agglayer/aggkit/tree/types" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +// stubReadTreer is a minimal treetypes.ReadTreer that returns a canned proof, used to exercise +// LocalExitTree.Proof without a real SQLite-backed tree. +type stubReadTreer struct { + proof treetypes.Proof +} + +func (s stubReadTreer) GetProof(_ context.Context, _ uint32, _ common.Hash) (treetypes.Proof, error) { + return s.proof, nil +} + +func (s stubReadTreer) GetRootByIndex(_ context.Context, _ uint32) (treetypes.Root, error) { + return treetypes.Root{}, nil +} + +func (s stubReadTreer) GetRootByHash(_ context.Context, _ common.Hash) (*treetypes.Root, error) { + return nil, nil +} + +func (s stubReadTreer) GetLastRoot(_ dbtypes.Querier) (treetypes.Root, error) { + return treetypes.Root{}, nil +} + +func (s stubReadTreer) GetLeaf(_ dbtypes.Querier, _ uint32, _ common.Hash) (common.Hash, error) { + return common.Hash{}, nil +} + +func TestLocalExitTreeDepositCount(t *testing.T) { + t.Parallel() + + leaf := common.HexToHash("0xabc") + lt := &LocalExitTree{depositCount: map[common.Hash]uint32{leaf: 7}} + + dc, ok := lt.DepositCount(leaf) + require.True(t, ok) + require.Equal(t, uint32(7), dc) + + _, ok = lt.DepositCount(common.HexToHash("0xdead")) + require.False(t, ok) +} + +func TestLocalExitTreeMetadata(t *testing.T) { + t.Parallel() + + leaf := common.HexToHash("0xabc") + lt := &LocalExitTree{metadata: map[common.Hash][]byte{leaf: {0x01, 0x02}}} + + m, ok := lt.Metadata(leaf) + require.True(t, ok) + require.Equal(t, []byte{0x01, 0x02}, m) + + _, ok = lt.Metadata(common.HexToHash("0xdead")) + require.False(t, ok) +} + +func TestLocalExitTreeProof(t *testing.T) { + t.Parallel() + + var want treetypes.Proof + want[0] = common.HexToHash("0x99") + lt := &LocalExitTree{tree: stubReadTreer{proof: want}} + + got, err := lt.Proof(context.Background(), 0, common.Hash{}) + require.NoError(t, err) + require.Equal(t, want, got) +} + +func TestLocalExitTreeCloseNilDB(t *testing.T) { + t.Parallel() + + lt := &LocalExitTree{} + require.NoError(t, lt.Close()) +} diff --git a/tools/exit_certificate_claimer/service/run.go b/tools/exit_certificate_claimer/service/run.go new file mode 100644 index 000000000..0dc704f2b --- /dev/null +++ b/tools/exit_certificate_claimer/service/run.go @@ -0,0 +1,80 @@ +package claimer + +import ( + "context" + "fmt" + "os" + "os/signal" + "syscall" + + "github.com/agglayer/aggkit/log" + "github.com/urfave/cli/v2" +) + +// Run is the urfave/cli action entry point: it loads the config, opens the data sources, and runs +// the HTTP server until interrupted. +func Run(c *cli.Context) error { + logLevel := "info" + if c.Bool("verbose") { + logLevel = "debug" + } + log.Init(log.Config{ + Environment: log.EnvironmentDevelopment, + Level: logLevel, + Outputs: []string{"stderr"}, + }) + logger := log.WithFields("module", "exit-certificate-claimer") + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + cfg, err := loadOrDeriveConfig(ctx, c, logger) + if err != nil { + return err + } + + cert, err := LoadCertificate(cfg.SignedCertificatePath) + if err != nil { + return err + } + logger.Infof("loaded certificate: network %d, %d bridge exits, new local exit root %s", + cert.NetworkID, len(cert.Leaves), cert.NewLocalExitRoot.Hex()) + + waitResult, err := LoadStepWaitResult(cfg.StepWaitResultPath) + if err != nil { + return err + } + logger.Infof("loaded wait result: certificate %s settled (status %s)", + waitResult.CertificateHash.Hex(), waitResult.FinalStatus) + + settlementGER, err := SettlementGER(waitResult) + if err != nil { + return err + } + + localTree, err := OpenLocalExitTree(ctx, cfg.LocalExitTreeDBPath, logger) + if err != nil { + return err + } + defer func() { + if closeErr := localTree.Close(); closeErr != nil { + logger.Warnf("closing local exit tree: %v", closeErr) + } + }() + + l1, err := OpenL1InfoTree(ctx, cfg.L1Sync, cfg.L1InfoTreeDBPath, settlementGER, logger) + if err != nil { + return err + } + + claimer := NewClaimer(logger, cert, localTree, l1, cfg.NetworkID, waitResult) + if err := claimer.Check(ctx); err != nil { + return fmt.Errorf("claimer check: %w", err) + } + server := NewServer(cfg, claimer, logger) + + if err := server.Start(ctx); err != nil { + return fmt.Errorf("server stopped: %w", err) + } + return nil +} diff --git a/tools/exit_certificate_claimer/service/server.go b/tools/exit_certificate_claimer/service/server.go new file mode 100644 index 000000000..1b909e460 --- /dev/null +++ b/tools/exit_certificate_claimer/service/server.go @@ -0,0 +1,169 @@ +package claimer + +import ( + "context" + "errors" + "net/http" + "strconv" + "time" + + "github.com/agglayer/aggkit/log" + "github.com/ethereum/go-ethereum/common" + "github.com/gin-gonic/gin" +) + +const ( + apiBasePath = "/claimer/v1" + destAddressParam = "dest_address" + depositCountParam = "deposit_count" + shutdownTimeout = 5 * time.Second +) + +// Server exposes the claimer over HTTP using Gin. +type Server struct { + logger *log.Logger + address string + readTimeout time.Duration + writeTimeout time.Duration + claimer *Claimer + router *gin.Engine +} + +// NewServer builds the HTTP server and registers the routes. +func NewServer(cfg *Config, claimer *Claimer, logger *log.Logger) *Server { + gin.SetMode(gin.ReleaseMode) + router := gin.New() + router.Use(gin.Recovery()) + + s := &Server{ + logger: logger, + address: cfg.ListenAddress(), + readTimeout: time.Duration(cfg.ReadTimeoutSeconds) * time.Second, + writeTimeout: time.Duration(cfg.WriteTimeoutSeconds) * time.Second, + claimer: claimer, + router: router, + } + + v1 := router.Group(apiBasePath) + v1.GET("/health", s.handleHealth) + v1.GET("/bridges", s.handleBridges) + v1.GET("/claim-params", s.handleClaimParams) + + return s +} + +// Start runs the HTTP server until the context is cancelled, then shuts it down gracefully. +func (s *Server) Start(ctx context.Context) error { + srv := &http.Server{ + Addr: s.address, + Handler: s.router, + ReadTimeout: s.readTimeout, + WriteTimeout: s.writeTimeout, + } + + errCh := make(chan error, 1) + go func() { + s.logger.Infof("claimer backend listening on %s (base path %s)", s.address, apiBasePath) + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + errCh <- err + } + }() + + select { + case err := <-errCh: + return err + case <-ctx.Done(): + shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout) + defer cancel() + return srv.Shutdown(shutdownCtx) + } +} + +func (s *Server) handleHealth(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"status": "ok", "network_id": s.claimer.NetworkID()}) +} + +func (s *Server) handleBridges(c *gin.Context) { + destAddr, ok := s.parseDestAddress(c) + if !ok { + return + } + + bridges, err := s.claimer.ListBridges(destAddr) + if err != nil { + s.respondError(c, http.StatusInternalServerError, err) + return + } + + c.JSON(http.StatusOK, BridgesResponse{ + NetworkID: s.claimer.NetworkID(), + DestinationAddress: destAddr.Hex(), + Bridges: bridges, + }) +} + +func (s *Server) handleClaimParams(c *gin.Context) { + destAddr, ok := s.parseDestAddress(c) + if !ok { + return + } + + depositCount, ok := s.parseDepositCount(c) + if !ok { + return + } + + claims, err := s.claimer.BuildClaimParams(c.Request.Context(), destAddr, depositCount) + if err != nil { + status := http.StatusInternalServerError + if errors.Is(err, ErrLocalExitRootNotSettled) { + status = http.StatusConflict + } + s.respondError(c, status, err) + return + } + + c.JSON(http.StatusOK, ClaimParamsResponse{ + NetworkID: s.claimer.NetworkID(), + DestinationAddress: destAddr.Hex(), + Claims: claims, + }) +} + +// parseDestAddress reads and validates the dest_address query param, writing a 400 on failure. +func (s *Server) parseDestAddress(c *gin.Context) (common.Address, bool) { + raw := c.Query(destAddressParam) + if raw == "" { + s.respondErrorMsg(c, http.StatusBadRequest, destAddressParam+" query parameter is required") + return common.Address{}, false + } + if !common.IsHexAddress(raw) { + s.respondErrorMsg(c, http.StatusBadRequest, "invalid "+destAddressParam+": "+raw) + return common.Address{}, false + } + return common.HexToAddress(raw), true +} + +// parseDepositCount reads the optional deposit_count query param. Returns (nil, true) when absent +// (all matching exits are returned), writing a 400 only on a malformed value. +func (s *Server) parseDepositCount(c *gin.Context) (*uint32, bool) { + raw := c.Query(depositCountParam) + if raw == "" { + return nil, true + } + v, err := strconv.ParseUint(raw, 10, 32) + if err != nil { + s.respondErrorMsg(c, http.StatusBadRequest, "invalid "+depositCountParam+": "+raw) + return nil, false + } + dc := uint32(v) + return &dc, true +} + +func (s *Server) respondError(c *gin.Context, status int, err error) { + s.respondErrorMsg(c, status, err.Error()) +} + +func (s *Server) respondErrorMsg(c *gin.Context, status int, msg string) { + c.JSON(status, errorResponse{Error: msg}) +} diff --git a/tools/exit_certificate_claimer/service/server_start_test.go b/tools/exit_certificate_claimer/service/server_start_test.go new file mode 100644 index 000000000..e63ead6ad --- /dev/null +++ b/tools/exit_certificate_claimer/service/server_start_test.go @@ -0,0 +1,68 @@ +package claimer + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// TestServerStartGracefulShutdown starts the HTTP server on an ephemeral port and verifies that +// cancelling the context triggers a clean shutdown (Start returns nil). +func TestServerStartGracefulShutdown(t *testing.T) { + t.Parallel() + + cert, err := LoadCertificate(writeSampleCert(t)) + require.NoError(t, err) + claimer, _ := buildTestClaimer(t, cert.NewLocalExitRoot) + // Port 0 lets the OS pick a free ephemeral port. + cfg := &Config{Address: "127.0.0.1", Port: 0, ReadTimeoutSeconds: 1, WriteTimeoutSeconds: 1} + srv := NewServer(cfg, claimer, claimer.logger) + + ctx, cancel := context.WithCancel(context.Background()) + errCh := make(chan error, 1) + go func() { errCh <- srv.Start(ctx) }() + + // Give the listener a moment to come up, then cancel to trigger graceful shutdown. + time.Sleep(50 * time.Millisecond) + cancel() + + select { + case err := <-errCh: + require.NoError(t, err) + case <-time.After(5 * time.Second): + t.Fatal("Start did not return after context cancellation") + } +} + +// TestServerStartListenError exercises the error branch: an unbindable address makes ListenAndServe +// fail immediately and Start returns that error. +func TestServerStartListenError(t *testing.T) { + t.Parallel() + + cert, err := LoadCertificate(writeSampleCert(t)) + require.NoError(t, err) + claimer, _ := buildTestClaimer(t, cert.NewLocalExitRoot) + // An out-of-range port cannot be bound. + cfg := &Config{Address: "127.0.0.1", Port: 99999999, ReadTimeoutSeconds: 1, WriteTimeoutSeconds: 1} + srv := NewServer(cfg, claimer, claimer.logger) + + err = srv.Start(context.Background()) + require.Error(t, err) +} + +func TestParseLeafTypeMessage(t *testing.T) { + t.Parallel() + + lt, err := parseLeafType("Message") + require.NoError(t, err) + require.Equal(t, leafTypeMessage, lt) + + lt, err = parseLeafType("Transfer") + require.NoError(t, err) + require.Equal(t, leafTypeAsset, lt) + + _, err = parseLeafType("bogus") + require.Error(t, err) +} diff --git a/tools/exit_certificate_claimer/service/server_test.go b/tools/exit_certificate_claimer/service/server_test.go new file mode 100644 index 000000000..94bb094a4 --- /dev/null +++ b/tools/exit_certificate_claimer/service/server_test.go @@ -0,0 +1,114 @@ +package claimer + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +// newTestServer builds a Server backed by a claimer whose local exit root matches the certificate +// (so claim params resolve) and returns it ready to receive httptest requests. +func newTestServer(t *testing.T) (*Server, common.Address) { + t.Helper() + + cert, err := LoadCertificate(writeSampleCert(t)) + require.NoError(t, err) + + claimer, _ := buildTestClaimer(t, cert.NewLocalExitRoot) + cfg := &Config{Address: "127.0.0.1", Port: 7080, ReadTimeoutSeconds: 1, WriteTimeoutSeconds: 1} + srv := NewServer(cfg, claimer, claimer.logger) + return srv, cert.Leaves[0].DestinationAddress +} + +func doRequest(t *testing.T, srv *Server, target string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(http.MethodGet, target, nil) + rec := httptest.NewRecorder() + srv.router.ServeHTTP(rec, req) + return rec +} + +func TestServerHealth(t *testing.T) { + t.Parallel() + + srv, _ := newTestServer(t) + rec := doRequest(t, srv, apiBasePath+"/health") + require.Equal(t, http.StatusOK, rec.Code) + + var body map[string]any + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body)) + require.Equal(t, "ok", body["status"]) + require.Equal(t, float64(1), body["network_id"]) +} + +func TestServerBridges(t *testing.T) { + t.Parallel() + + srv, destAddr := newTestServer(t) + rec := doRequest(t, srv, apiBasePath+"/bridges?dest_address="+destAddr.Hex()) + require.Equal(t, http.StatusOK, rec.Code) + + var resp BridgesResponse + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + require.Equal(t, uint32(1), resp.NetworkID) + require.Equal(t, destAddr.Hex(), resp.DestinationAddress) + require.Len(t, resp.Bridges, 1) +} + +func TestServerBridgesMissingDestAddress(t *testing.T) { + t.Parallel() + + srv, _ := newTestServer(t) + rec := doRequest(t, srv, apiBasePath+"/bridges") + require.Equal(t, http.StatusBadRequest, rec.Code) + + var resp errorResponse + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + require.Contains(t, resp.Error, destAddressParam+" query parameter is required") +} + +func TestServerBridgesInvalidDestAddress(t *testing.T) { + t.Parallel() + + srv, _ := newTestServer(t) + rec := doRequest(t, srv, apiBasePath+"/bridges?dest_address=not-an-address") + require.Equal(t, http.StatusBadRequest, rec.Code) +} + +func TestServerClaimParams(t *testing.T) { + t.Parallel() + + srv, destAddr := newTestServer(t) + rec := doRequest(t, srv, apiBasePath+"/claim-params?dest_address="+destAddr.Hex()) + require.Equal(t, http.StatusOK, rec.Code) + + var resp ClaimParamsResponse + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + require.Len(t, resp.Claims, 1) + require.Equal(t, uint32(5), resp.Claims[0].DepositCount) +} + +func TestServerClaimParamsInvalidDepositCount(t *testing.T) { + t.Parallel() + + srv, destAddr := newTestServer(t) + rec := doRequest(t, srv, + apiBasePath+"/claim-params?dest_address="+destAddr.Hex()+"&deposit_count=not-a-number") + require.Equal(t, http.StatusBadRequest, rec.Code) +} + +func TestServerClaimParamsNotSettledConflict(t *testing.T) { + t.Parallel() + + // A claimer whose settled local exit root does not match the certificate yields a 409. + claimer, destAddr := buildTestClaimer(t, common.HexToHash("0xdeadbeef")) + cfg := &Config{Address: "127.0.0.1", Port: 7080, ReadTimeoutSeconds: 1, WriteTimeoutSeconds: 1} + srv := NewServer(cfg, claimer, claimer.logger) + + rec := doRequest(t, srv, apiBasePath+"/claim-params?dest_address="+destAddr.Hex()) + require.Equal(t, http.StatusConflict, rec.Code) +} diff --git a/tools/exit_certificate_claimer/service/types.go b/tools/exit_certificate_claimer/service/types.go new file mode 100644 index 000000000..730ebdf2c --- /dev/null +++ b/tools/exit_certificate_claimer/service/types.go @@ -0,0 +1,99 @@ +package claimer + +import ( + "encoding/hex" + "math/big" + + treetypes "github.com/agglayer/aggkit/tree/types" + "github.com/ethereum/go-ethereum/common" +) + +// leaf_type values as serialized in the signed exit certificate. +const ( + leafTypeTransferStr = "Transfer" + leafTypeMessageStr = "Message" + + leafTypeAsset uint8 = 0 + leafTypeMessage uint8 = 1 +) + +// BridgeExitView is the public, JSON-friendly representation of a single bridge exit +// destined for a given address. It mirrors the certificate entry and is enriched with +// the deposit count (the exit-tree leaf index) resolved from the local exit tree DB. +type BridgeExitView struct { + LeafType uint8 `json:"leaf_type"` + OriginNetwork uint32 `json:"origin_network"` + OriginTokenAddress string `json:"origin_token_address"` + DestinationNetwork uint32 `json:"destination_network"` + DestinationAddress string `json:"destination_address"` + Amount string `json:"amount"` + Metadata string `json:"metadata"` + DepositCount uint32 `json:"deposit_count"` +} + +// ClaimAssetParams holds every argument required to call AgglayerBridge.claimAsset for a +// single bridge exit, serialized in a JSON/web-friendly form (hex strings, decimal amounts). +type ClaimAssetParams struct { + SmtProofLocalExitRoot [treetypes.DefaultHeight]string `json:"smt_proof_local_exit_root"` + SmtProofRollupExitRoot [treetypes.DefaultHeight]string `json:"smt_proof_rollup_exit_root"` + GlobalIndex string `json:"global_index"` + MainnetExitRoot string `json:"mainnet_exit_root"` + RollupExitRoot string `json:"rollup_exit_root"` + OriginNetwork uint32 `json:"origin_network"` + OriginTokenAddress string `json:"origin_token_address"` + DestinationNetwork uint32 `json:"destination_network"` + DestinationAddress string `json:"destination_address"` + Amount string `json:"amount"` + Metadata string `json:"metadata"` + + // Context fields (not claimAsset arguments) useful for callers and debugging. + LeafType uint8 `json:"leaf_type"` + DepositCount uint32 `json:"deposit_count"` + L1InfoTreeIndex uint32 `json:"l1_info_tree_index"` +} + +// BridgesResponse is the body returned by GET /bridges. +type BridgesResponse struct { + NetworkID uint32 `json:"network_id"` + DestinationAddress string `json:"destination_address"` + Bridges []BridgeExitView `json:"bridges"` +} + +// ClaimParamsResponse is the body returned by GET /claim-params. +type ClaimParamsResponse struct { + NetworkID uint32 `json:"network_id"` + DestinationAddress string `json:"destination_address"` + Claims []ClaimAssetParams `json:"claims"` +} + +// errorResponse is the JSON body returned on error. +type errorResponse struct { + Error string `json:"error"` +} + +// proofToHex converts a tree.Proof (32 sibling hashes) into its hex-string representation. +func proofToHex(p treetypes.Proof) [treetypes.DefaultHeight]string { + var out [treetypes.DefaultHeight]string + for i := range p { + out[i] = p[i].Hex() + } + return out +} + +// bigToString renders a *big.Int as a decimal string, treating nil as "0". +func bigToString(v *big.Int) string { + if v == nil { + return "0" + } + return v.String() +} + +// addrHex renders an address as a checksummed 0x string. +func addrHex(a common.Address) string { + return a.Hex() +} + +// metadataHex renders a metadata byte blob as a 0x-prefixed hex string ("0x" for empty). +func metadataHex(b []byte) string { + return "0x" + hex.EncodeToString(b) +} diff --git a/tools/exit_certificate_claimer/service/types_test.go b/tools/exit_certificate_claimer/service/types_test.go new file mode 100644 index 000000000..fbea9ec6a --- /dev/null +++ b/tools/exit_certificate_claimer/service/types_test.go @@ -0,0 +1,49 @@ +package claimer + +import ( + "math/big" + "testing" + + treetypes "github.com/agglayer/aggkit/tree/types" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +func TestProofToHex(t *testing.T) { + t.Parallel() + + var proof treetypes.Proof + proof[0] = common.HexToHash("0x01") + proof[1] = common.HexToHash("0x02") + + out := proofToHex(proof) + require.Len(t, out, int(treetypes.DefaultHeight)) + require.Equal(t, common.HexToHash("0x01").Hex(), out[0]) + require.Equal(t, common.HexToHash("0x02").Hex(), out[1]) + // Unset siblings render as the zero hash. + require.Equal(t, common.Hash{}.Hex(), out[2]) +} + +func TestBigToString(t *testing.T) { + t.Parallel() + + require.Equal(t, "0", bigToString(nil)) + require.Equal(t, "0", bigToString(new(big.Int))) + require.Equal(t, "12345", bigToString(big.NewInt(12345))) +} + +func TestAddrHex(t *testing.T) { + t.Parallel() + + require.Equal(t, "0x0000000000000000000000000000000000000000", addrHex(common.Address{})) + addr := common.HexToAddress("0x0b68058e5b2592b1f472adfe106305295a332a7c") + require.Equal(t, addr.Hex(), addrHex(addr)) +} + +func TestMetadataHex(t *testing.T) { + t.Parallel() + + require.Equal(t, "0x", metadataHex(nil)) + require.Equal(t, "0x", metadataHex([]byte{})) + require.Equal(t, "0xabcd", metadataHex([]byte{0xab, 0xcd})) +} diff --git a/tools/exit_certificate_claimer/service/waitresult.go b/tools/exit_certificate_claimer/service/waitresult.go new file mode 100644 index 000000000..8606a93c4 --- /dev/null +++ b/tools/exit_certificate_claimer/service/waitresult.go @@ -0,0 +1,42 @@ +package claimer + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/agglayer/aggkit/l1infotreesync" + exitcertificate "github.com/agglayer/aggkit/tools/exit_certificate" + "github.com/ethereum/go-ethereum/common" +) + +// LoadStepWaitResult reads and parses step-wait-result.json produced by the exit_certificate WAIT +// step. It records the certificate's L1 settlement — the VerifyBatchesTrustedAggregator event and +// the accompanying L1 Info Tree update — identifying the exact L1 info tree leaf the certificate +// settled at. +func LoadStepWaitResult(path string) (*exitcertificate.StepWaitResult, error) { + raw, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("reading wait result %q: %w", path, err) + } + + var result exitcertificate.StepWaitResult + if err := json.Unmarshal(raw, &result); err != nil { + return nil, fmt.Errorf("parsing wait result %q: %w", path, err) + } + return &result, nil +} + +// SettlementGER derives the Global Exit Root the certificate settled at, from the WAIT step's +// UpdateL1InfoTree event — keccak256(mainnetExitRoot, rollupExitRoot), the same hashing the L1 +// GlobalExitRoot contract uses. It errors when the wait result did not capture that event. +func SettlementGER(result *exitcertificate.StepWaitResult) (common.Hash, error) { + if result.UpdateL1InfoTree == nil { + return common.Hash{}, fmt.Errorf( + "wait result has no updateL1InfoTree event; cannot derive the settlement GER") + } + return l1infotreesync.CalculateGER( + result.UpdateL1InfoTree.MainnetExitRoot, + result.UpdateL1InfoTree.RollupExitRoot, + ), nil +} diff --git a/tools/exit_certificate_claimer/service/waitresult_test.go b/tools/exit_certificate_claimer/service/waitresult_test.go new file mode 100644 index 000000000..d2a7d1268 --- /dev/null +++ b/tools/exit_certificate_claimer/service/waitresult_test.go @@ -0,0 +1,78 @@ +package claimer + +import ( + "os" + "path/filepath" + "testing" + + "github.com/agglayer/aggkit/l1infotreesync" + exitcertificate "github.com/agglayer/aggkit/tools/exit_certificate" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +const sampleWaitResult = `{ + "certificateHash": "0x1234000000000000000000000000000000000000000000000000000000000000", + "finalStatus": "Settled", + "updateL1InfoTree": { + "mainnetExitRoot": "0x1111000000000000000000000000000000000000000000000000000000000000", + "rollupExitRoot": "0x2222000000000000000000000000000000000000000000000000000000000000", + "txHash": "0x3333000000000000000000000000000000000000000000000000000000000000" + } +}` + +func TestLoadStepWaitResult(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "step-wait-result.json") + require.NoError(t, os.WriteFile(path, []byte(sampleWaitResult), 0o600)) + + result, err := LoadStepWaitResult(path) + require.NoError(t, err) + require.Equal(t, + common.HexToHash("0x1234000000000000000000000000000000000000000000000000000000000000"), + result.CertificateHash) + require.NotNil(t, result.UpdateL1InfoTree) + require.Equal(t, + common.HexToHash("0x1111000000000000000000000000000000000000000000000000000000000000"), + result.UpdateL1InfoTree.MainnetExitRoot) + require.Equal(t, + common.HexToHash("0x2222000000000000000000000000000000000000000000000000000000000000"), + result.UpdateL1InfoTree.RollupExitRoot) +} + +func TestLoadStepWaitResultErrors(t *testing.T) { + t.Parallel() + + _, err := LoadStepWaitResult(filepath.Join(t.TempDir(), "missing.json")) + require.ErrorContains(t, err, "reading wait result") + + badPath := filepath.Join(t.TempDir(), "bad.json") + require.NoError(t, os.WriteFile(badPath, []byte(`{not json`), 0o600)) + _, err = LoadStepWaitResult(badPath) + require.ErrorContains(t, err, "parsing wait result") +} + +func TestSettlementGER(t *testing.T) { + t.Parallel() + + mainnet := common.HexToHash("0x1111") + rollup := common.HexToHash("0x2222") + result := &exitcertificate.StepWaitResult{ + UpdateL1InfoTree: &exitcertificate.L1InfoTreeUpdate{ + MainnetExitRoot: mainnet, + RollupExitRoot: rollup, + }, + } + + ger, err := SettlementGER(result) + require.NoError(t, err) + require.Equal(t, l1infotreesync.CalculateGER(mainnet, rollup), ger) +} + +func TestSettlementGERMissingUpdate(t *testing.T) { + t.Parallel() + + _, err := SettlementGER(&exitcertificate.StepWaitResult{}) + require.ErrorContains(t, err, "no updateL1InfoTree event") +}